Attitudes That Lead to Maintainable Code
spin.atomicobject.com
spin.atomicobject.com
It is good to avoid future discounting in this way.
Programming-wise it's been kind of dull, actually. No fancy clever stuff. At the same time it's been demanding in terms of understanding the business perspective on things. How do you define "costs accumulated on a given project" or "coverage contribution of a given project"? Having no background in business management or accounting, I have little to no clue, but it sure was interesting to find out.
So, yeah, we're in the same boat, I guess. ;-)
This is an expedient way of presenting difficulties encountered in software development. Suppose you need to collate information from many different processes that are distributed in space and time, possibly pursuing the information from a series of back-up sources when the primary source fails, and providing on demand, at any point along the way, a summary of results taking into account pending results and failures.
This is a straightforward problem for a human. A person does not need special training to pick up the phone when their boss calls and say, "The framer finished on Tuesday, the faucet supplier hasn't replied to any of the voice mails I left last week so I sent an email to the plumber since he's used them before, the electrician says the wiring won't pass inspection the way we asked him to do it, and the client approved a draw for $10k so there will be no problem paying for the tile on Friday." Someone who does not program computers does not see a need to analyze this problem and create a precise and detailed plan for how to pursue and summarize this kind information. The difficulty arises when attempting to do something like this with a computer.
Therefore, it's reasonable to describe it as a programming challenge. But you'd be foolish to. You're much better off calling it a business challenge, because getting it right matters to other people and the proper modifier for stuff that matters is "business." If it's "programming" then you're just amusing yourself on company time.
We've internalized this terminology to a sick degree. I took someone's dependent little brother to a diabetes appointment for them, and when I was summarizing the visit to her, I accidentally referred to the doctor's directions for taking the various medications as the "business logic." I meant to distinguish it from the things the doctor explained merely to satisfy my curiosity. As programmers we've had it drilled into us that "business" means something that matters to other people and can be discussed with them. Anything that is not "business" is at best mildly self-indulgent to mention and at worst selfish to even think about.
Therefore, we strive to describe everything we do as "business." This is how the statement
I rarely solve difficult programming challenges. I solve difficult business challenges all the time.
becomes a declaration of virtue.
As an illustration of how words affect how we think about things, replace the word "programming" with "engineering." We aren't ashamed of tackling "engineering" challenges. So there's an out when something is hard and matters but can't plausibly called a business challenge: call it an engineering challenge instead.
(A) Converting something that you understand and are satisfied with... into a form the computer can operate upon.
(B) Persuading other people to change their process or expectations into something that is sane and generally understandable.
The naive programmer sees that all of the difficulty has been introduced by the decision to solve the problem with a computer system. Therefore it is the programmer's responsibility to solve the difficult problem and justify the difficulty to others. Concurrency is not a problem for human beings, so if concurrency is a difficulty, the programmer must take the time to solve it, and if a non-programmer asks why he is taking so long, he must do his best to explain why concurrency is hard for computer programs.
The wise programmer shields himself by exposing as much of the difficulty as possible onto non-programmers without every mentioning the computer. Never mind that the task is well enough specified for a $15 an hour temp worker to carry out -- it is ridden with underspecified "business logic!" The naive programmer assumes that the system must operate under any reasonable ordering of events or failures. The wise programmer brings obscures scenarios up in meetings, suggests that such-and-such a scenario isn't valid, furrows his brow worriedly at the answer, and then proclaims that the "business rules" need to be "more completely specified" to handle these "previously out of scope cases."
Again, never mind that in the past a dozen different human beings with no special training performed the task reliably without needing to call meetings to find out how to do their job. Never mind that no difficulty existed until the introduction of a computer that needed to be programmed. The wise programmer understands that the task is difficult and he needs to make everyone else feel that difficulty so that he doesn't appear to be struggling with an easy assignment. Therefore a task that the business has been performing successfully since its inception must be found to be ridden with previously undiscovered "business challenges."
Obviously the absurdity bothers me. I didn't enjoy being the naive developer, and I don't think I would enjoy being the wise developer, either, if I were capable of it. That means I'm doomed never to work alone. Right now my team is eight months into a project that was supposed to produce a prototype in three months, and we are yet to produce anything beyond a handful of disconnected demos. If I was doing this project by myself, or if I was leading a team of people like me, we would have been relieved of responsibility a long time ago. But we're fine because we have a couple of wise developers who are capable of communicating the difficulties in the manner described above, by essentially teaching other parts of the company that they don't know how to do their jobs.
I dislike the reality and am intentionally painting an unflattering picture of it, but I'm not trying to assign blame or disparage any party, because I don't have a solution. I just thought I'd present it as a way of seeing things.
Please write maintainable code using common and broadly understood methods. There is no award for surprising me in how fancy your solution is, in fact the opposite.
While I can agree with "no award" part, I don't think "the opposite" should be true.
How do you know you got surprised because the method was "fancy" instead of because your knowledge was lacking?
It's totally possible my knowledge is lacking, which is why the conversation is critical. If I just need to be educated on why something I saw as complex was the right decision, so be it, and I look forward / enjoy those opportunities.
At the end of the day, I run the tech team and founded this company, while others might come and go, I need to feel ownership over the code, product and most importantly it's shortcomings. If I cannot own that openly, I cannot lead/direct others, and that is when technical teams really start to fall apart imo.
Often times, the most common methods are the most obfuscated and unclear.
Maybe this is my perspective just because I am a Java developer. Pulling in lots of poorly understood dependencies. Depending on annotations that change execution flow in complex ways that are very difficult to follow, even with a sophisticated IDE. Deploying into complex web containers. ORMs mapping method calls into who knows what SQL calls. Lots of superfluous mutable state everywhere.
(I'm looking at you Spring. Although Spring Boot is a definite improvement.)
The guy(s) who made annotations in Java 1.5 deserves extreme punishment :-)
They shouldn't have been allowed to actually change runtime behavior or be used to control code generation.
You make these broad statements about monads as if FP was the whole programming world. It's not. It's probably less than 5% of it.
Nope.
> State is encapsulated in an object and transformed by method calls.
Monads aren't objects (which necessarily have a runtime manifestation). Monads (as used in functional programming) are abstract type constructors (which normally don't have a runtime manifestation).
> Method calls can be chained, and state can be encapsulated and extracted using constructors and accessors, respectively.
Monadic bind satisfies three concrete, clearly defined algebraic laws. The use of monads in functional programming arose from the field of denotational semantics, which gives “programs” (by which functional language semanticists mean “expressions”) precise mathematical meanings in a compositional manner.
I didn't say Monads were objects. I simply talked through the most common properties and operations of OOP code, in order to show how they are closely related to the operations on Monads.
I understand that OOP code does not, in general, satisfy the constraints of Monads. Hence, why I specified high-quality OOP code, which, in contrast, typically does.
Last I checked, there are only three laws: left/right identity and associativity, and they are just algebraic laws that have nothing to do with your code being “good” or “bad”. Or do you have some other meaning of “rules of monads” in mind?
Furthermore, every higher-order strict language is already monadic, without you having to do anything special. Thus, monads aren't opt-in, or even opt-out; they're there whether you like it or not. This monadic structure permeates the language, regardless of whether you write “good” or “bad” programs in it. For more info, see here: https://news.ycombinator.com/item?id=13823145
Yes, all higher-order strict languages can be embedded in a monadic model. However, there's an important practical difference between a single Monad with the entire application or computer's state encapsulated (course grain encapsulation), and many Monads with fine-grained encapsulation. High-quality OOP code can be embedded in the latter, and that's where the maintainability comes from.
--- Perhaps, if you focused more on understanding the point I'm trying to make instead of proving me wrong, this conversation would be more beneficial to both of us.
And, by "intrinsic", do you mean that "the language does that, but the programmer doesn't have to do anything to make it happen, or even know"? If so, doesn't this contradict your answer to smaddox?
Procedures as first-class values that you can store in variables, pass as arguments to other procedures, etc.
> And, by "intrinsic", do you mean that "the language does that, but the programmer doesn't have to do anything to make it happen, or even know"?
Yes. Although, IMO, programmers benefit from knowing it, for more or less the same reasons civil engineers benefit from knowing the laws of nature.
> If so, doesn't this contradict your answer to smaddox?
No. Monads, as abstract type constructors, are already hardcoded into the result type of every computation. (This is the difference between, say, an int like “2”, and a computation that produces an int like “1 + 1” or “fix (\x -> x + 1)”.) So hardcoded, in fact, that there doesn't have to be syntax for it, because there's no way you can not say it.
If so, I don't know why the "higher-order" restriction is there. It seems unneeded. Also, it about is as much something "the programmer doesn't have to do anything or even know" as monadic code on languages that have specialized syntactic sugar, like Haskell. It creates plenty of details the programmer has to think about, the only difference is that in procedural languages all the details are always there, while in pure ones the detail set changes all the time.
Wow, you need to talk to more developers. Not just in the sense to broaden your views, but to improve your skills as a developer. Code is written to be read by humans (and orthogonally executed by machines). If your mental model of what is broadly understood by programmers is that far off, you are likely churning out lots of unmaintainable code.
Functional programming makes up a tiny slice of the programming world and most people that aren't doing functional programming (and even some who are) don't have a strong grasp of monads.
Will you get upset if someone in your java codebase uses lambdas or optionals or even the ternary operator? All of the above are still considered black magic to many in our industry.
If that's the case then we might want to a) educate everyone else first or b) pick something we know will be less of a struggle for others to maintain.
I guess there is always option c) ask the original author never to leave and be available 24/7 to answer questions, but that, obviously, isn't realistic.
Edit: I answered the question poorly. Are you "writing art" or doing your job? I don't mind if the outcome is pretty, but your first priority is to do your best in your role within the organization. If the author or the code feels that is the case then generally I won't ever get upset with anything they do.
The rest of the world can and will change their definition of what "common and broadly understood" means.
Assembler (Prince of Persia): https://github.com/jmechner/Prince-of-Persia-Apple-II
BASIC: http://softwareengineering.stackexchange.com/questions/14945...
My career in software development started with BASIC - Pick BASIC to be exact. It was 1992, and I was 18 years old, had moved to Phoenix a week after graduating from high school to attend a local tech school (now defunct), and I needed something to pay the rent after having my hours cut as a cashier for Osco Drugs.
Seeing my other classmates at the time taking "apprenticeship" positions doing electronics work, I decided to "cold-call" nearby companies, and landed on a "mom-n-pop" shop doing software development work. I put in my application, and they took a chance on me. They didn't hire me as a software developer, though - but as an "operator" - the guy to "run reports, keep paper in the printers, and mount tapes" for the actual programmers, to free up their time. I learned how to load a open-reel 9-track tape drive with vacuum columns at that place...something I have never used since.
I was "in heaven" - being paid much more than I was as a cashier (but finding out much later that I was actually "underpaid" - oh, the ignorance of youth!), while doing less work! They gave me access to an account on their IBM RS6000 system running AIX - and one of the guys gave me a book on PICK BASIC to look over, and play around with.
The shop developed "insurance claims management" software for the "Arizona Health Care Cost Containment System" (AHCCCS) - basically Arizona's form of Medicaid; this company's software managed claims (accounting, billing, etc). They used a form of PICK called "UniVerse":
https://en.wikipedia.org/wiki/Pick_operating_system
PICK (and UniVerse) were actually very interesting systems on their own; kinda a self-contained OS that integrated a programming language (BASIC) with a relational (and multi-value!) DB, along with other stuff. It compiled to a byte-code, which some manufacturers made CPUs to run (instead of interpreters); the idea was "portable software" across multiple architectures. Basically the idea of Java before Java. It worked out about as well, of course (because every implementation of PICK was just a bit different).
Anyhow - the programming language used was BASIC - and my experience up to that point was writing bits and bobs of BASIC (plus some assembler - for the 6809 and 6502 processors - interestingly the latter was for the Apple IIe - CALL -151 was my friend; hand assembly of op-codes in the monitor - ugh). Well - they were monitoring the account, as I coded up adventure games and other diversions in PICK BASIC. The programmers and owner started feeding me with tasks ("hey, can you fix this issue" or "can you build this demo for the printer", etc) - and I proved myself. They hired me after I had graduated the tech school, and I've been here in Phoenix ever since.
As far as BASIC and businesses are concerned - it is still used today, as much as everybody rants on it. Besides Visual Basic (large following there), PICK BASIC is still used (it has a legacy almost as long and broad as COBOL), and there are many other old "business BASICs" that existed and still have users today. I also know that there is at least one industrial robot arm manufacturer who sells a "Robot BASIC" to program and control their arms.
...and BASIC lives on today, of course - both closed and open-source variants. I'm more familiar with the various open-source BASICs. You have Gambas (which looks very much like VB), then there is QB64 (multi-platform 64 bit compiled QBASIC if you will), and also something called "BaCON", which is a "BASIC-to-C" converter (just the other day I was thinking taking it, and dumping out some Web Assembly code - essentially to see if I could get a BASIC program compiled to run in a web browser). There are also more than a few interpreters in javascript and other languages that can run BASIC code of some sort or another.
Would I recommend it for new projects of large scope? Probably not, but I wouldn't outright discount it, either. I think it still has its place in the computing world, though that world is doing its best to ignore it in the hope that it is forgotten. It did teach some "bad habits" - but I think that a modern form of it, with proper OOP support, could be a very powerful teaching language for beginners - which is what it was always meant to be.
It will always have a spot near and dear to my heart, at least.
I often ruminate about the graveyard of dying software - the BASICs, the COBOLs, the xBases, the Delphis and the PowerBuilders, to name just the few I know. And the immense amount of code written and souls poured into by programmers over the years.
They're all there, there is still juice in them, but we move on because, impermanence. This was also a theme in _why's parting gift. https://news.ycombinator.com/item?id=5575707
Isn't just MOV enough for "moving mountains"? https://www.cl.cam.ac.uk/~sd601/papers/mov.pdf
It's not about "programming cleverness" - there's nothing "clever" about monads, rather the monad is a very simple and boring interface that turns out to be a good tool for helping you write code that does what it looks like.
Take the Linux kernel, NASA rovers, or redis - all written in boring old C with no monads and great results.
The only one that comes to mind is Spark, and while I haven't used it recently, was nightmarishly buggy with many subtle corner cases :/
Even jumping outside of C, Atom, Eclipse, Chrome, FireFox all are maintained by large groups of people and dont have a single monad!
NASA's projects that do hit this standard tend to be written in CL with macros that do this.
I haven't read redis; anyone else?
The sheer number of committers guarantees bugs. That is not a remarkable feature. The remarkable number of committers is.
Perhaps another metric is how long after your code is released will you be able to support it? In which case Linux and NASA do quite well.
This is a good read: http://web.cecs.pdx.edu/~antoy/Courses/TPFLP/lectures/MONADS...
A monad is basically an function, in the algebra sense, that will take a set of input, and map it to a different output.
That's the most simple definition I can give without getting into the math, and it's been a minute since I've really gotten into Group theory.
Maybe through a Haskell tutorial for some hands-on guidance?
http://m50d.github.io/2013/01/16/generic-contexts is an explanation of a case where I found myself using the Applicative interface - Monad is very similar to Applicative (it just adds one extra possible operation).
That turns out to be extremely useful in many cases.
Even so, I don't believe that monads are the only way to write good code.
In short they are way to automate building new types out of old types in such a way that code written for the old types can immediately be turned into code written for the new types. This allows a lot of otherwise repetitive code that you might otherwise wind up writing to be pushed to your type system. Which in turn makes it feasible to build a richer type system.
Haskell takes this idea to an extreme.
But that is a trivial statement considering that a Turing-complete subset of most (all?) languages is relatively simple.
The point addressed by the article is maintainable code, which is something else.
Clever solutions and abstractions (hey, immutability is clever right?) can really help out reasoning.
And sometimes you really need clever things due to problem domain, so you really end up with totally not boring code.
For example, you wouldn't cook up your own matrix manipulation everywhere, so you use a clever solution instead. Writing operations directly opposed to composing and optimizing them.
Or in high performance world, using a much more complex but lock-free algorithm instead of the "bog-standard" locked one, sometimes the benefit is huge.
The real important part is to exactly document the clever parts and enforce contracts on them, so you do not end up with potpourri everywhere.
You CAN solve this with counters and locks (be careful about deadlocks though), but you will save yourself a lot of pain if you just use a FRP framework. You will also be able to easily react to the things you forgot ("Oh the requests should also be halted and retried after connectivity is lost").
So where do you draw the line? Do you spare your coworkers the agony of learning FRP or do you implement something where you suspect it will horribly crash and burn in production?
This took me 10 years to realize.
I find myself getting rid of everything clever, my code reads like a boring instruction manual.
I'm starting to deeply re-think how code interviews should be conducted.
Instead of asking smarty-pants questions, we should be asking mediocre questions and just asking someone to put together a straight-forward and simple solution.
Important: it's surprisingly difficult to make code that looks simple and boring :), it often takes some iteration.
1. Code to an interface, not an implementation. Think about the essence of the class, without which, it would be a different class. Then build an API for that essence, then implement the API.
2. Program into a language, not in a language. Work out first what you want to achieve, then figure the best way of achieving that in the language.
3. Tell, don't ask. Don't expect client systems to know how your system works. When the state of your domain has changed, tell other systems, don't wait for them to ask.
The "Factorio" game is one of the best explanations of good programming I've ever come across. Build components that do one thing well, then build components that use those components, and keep growing the system in that way. When you want to keep the relationship between two components more flexible, push values from the first component onto a 'conveyor belt' and have the second component pick up values off that belt. Service oriented architecture in game form.
My method is now to implement a 'dirty' integration, without any care of structure or cleanliness, then when it's in place and working only then do I sit down and look at the API I would have liked to have, and implement this by mostly refactoring what is already working.
With that method, you end up with something that is clean, and more importantly does exactly what is needed an no more. There is no bits 'that could be useful later' and are never implemented, or used, or tested, and the model fits the job.
These days I'm all for the "dirty integration" first.
The real issue is code abstracted bottom-up instead of proper design. Such abstractions end up being special cases and become annoying and worthless if requirements (incl. internal ones) change.
While sometimes you can design yourself into a bad end (typically by not taking a really critical requirement into consideration), this is not what often happens.
Exactly. We're (basically) saying the same thing here. I kept my comment short-and-sweet as not to get into the nitty-gritty of everyone's different work situations/requirements/stances on different testing practices/building an app vs a library or service/programming paradigm/etc.
To expand ever so slightly: "These days I'm all about 'dirty integration' first to help inform my ultimate design."
> With that method, you end up with something that is clean, and more importantly does exactly what is needed an no more. There is no bits 'that could be useful later' and are never implemented, or used, or tested, and the model fits the job.
You want a clean abstraction that gets the job done, and cleanness is better than strict minimality. If you leave big gaps in your abstraction, then it's not a good abstraction anymore, and you'll probably have problems down the road (especially once things get transferred to a support group). It's definitely a judgement call how far you should take this, however. Generally, I want to do what I can to avoid tempting future developers to put new features at the wrong level of abstraction.
Make it work is the dirty version, always comes first without thought of proper factoring or abstractions. Once working and under test, it's much easier to then make it right, which is factoring out duplication and fixing up the code to be clean and well factored. Then and only then if something is too slow and a profiler shows a problem do you make it faster.
Short version rephrasing of the rule: put a prototype into production.
You can see how it is obviously bad.
Typically cleaning up a dirty version is VERY hard. Takes much more time than rewriting assuming you wrote proper functional tests.
Likewise you cannot make a truly wrong design really fast without redoing it.
I would flip the rule on the head: design so that it can be made fast, is easy to get right, therefore will work.
It is usually better to just write it out and discuss instead of following through based only on your own gut, however good it is.
Now if you have the experience to know beforehand what abstractions you need because you've done this many times, of course you can start cleaner; you do things dirty when you're unsure of what abstractions you might need and don't want to get hung up in trying to invent an abstraction before you know you need it.
Also, for a 'public' API, I would definitely go for a bit more abstraction, and would attempt at making as future proof as practical. Of course you can ALSO fall on the other side of the fence with that by providing an API that is so 'future proofed' that they become next to unusable without another API on top! Apple was the Grand Specialist at that in the 90's.
My reply to the post two level up was more regarding /internal/ APIs, the ones you create on top of an existing library for example, to integrate into your own systems.
If you never "refactor" your factories in Factorio, you'll end up with an undefendable and inefficient mess. Spaghetti factories indeed. But if you stop to think, spend some time refactoring how things are working, and build to your new technologies, you'll end up with a megafactory which can help you escape with no problem.
It also highlights the importance of experience and experimentation; finding the proper ratios of furnaces to miners, maximizing the bandwidth of a conveyer belt while minimizing the stagnant materials; once you learn this, your next playthrough will be both faster and require less refactoring.
As for SOA, one thing Factorio taught me was the value of back pressure. Why spend resources producing materials which aren't being consumed? Conveyer belts and movers show the value of backpressure nicely - when the belt is full, movers stop placing items on the belt. The factories stop producing, and thus stop consuming intermediate materials... all the way back to your miners. But the moment that belt starts to clear up, the entire mechanism swings back into work, with enough queued materials to ensure that you are not suddenly starved.
The parallels with programming are a bit more indirect than with actual material production, but they do exist.
This bit of your comment reminded me of The Goal: A Process of Ongoing Improvement[1] by Eliyahu Goldratt. It's a great fictional story/book that does an excellent job of teaching about bottlenecks and the Theory of Constraints. Specifically how to identify them and eliminate them.
We had a coworker implement a custom Either object in Java instead of just using exceptions, and it made the interface significantly less ergonomic. I really like functional return values like that, but I've found that it makes things easier to use the paradigms the language provides.
That is, solve the problem I am trying to solve. Do not go out of the way to solve other problems. They will have their time, or they were not important.
Obviously, it took some time to get so that I could write code that wasn't a disaster on its own. I would argue I haven't gotten past that point, actually. Which is all the more reason that the less I touch to solve a problem, the better.
You don't want to create extra problems for future you to solve.
The best solutions are in the middle ground, viz. those that solve the immediate problem in a way that anticipates and facilitates the future as well.
But, you have to exercise caution and prioritize. Otherwise, you'll just find yourself in the "Hal fixing a lightbulb"[1] cycle.
What has worked for me is embracing change.
Requirements will change, third party integrations will change, front end frameworks, use cases, service providers etc.
Everything will change so write (really build) code that tolerates change (IOC, interfaces, etc. Any form of loose coupling you can get away with cleanly).
There are of course situations when the one line fix is the right one. But in general, this outlook on short term vs long term changes (knowing when the concrete should become the abstract) is one of the differences between being a programmer and a software engineer (or being a senior and a junior).
Everything might change. Not everything will change.
I think the problem I have with your principle is that many of the techniques to accommodate change (the "IOC, interfaces, etc" you mention) can have a real and immediate cost when it comes to understanding a codebase, and that's the fundamental sin when it comes to maintainability.
Abstraction and indirection have value, obviously, but on balance I'm with GP; don't add 'em until you actually need 'em. In particular, don't add an interface and a factory and whatnot until you actually have more than one implementation.
This may depend on your languages and dev environment; something like IDEA where large-scale but mechanically trivial refactorings can be accomplished quickly and safely are more suited to KISS than e.g. dynamic languages where tooling is much much weaker.
True. Not everything will change. But you don't know which ones will and which ones won't. So be ready.
> I think the problem I have with your principle is that many of the techniques to accommodate change (the "IOC, interfaces, etc" you mention) can have a real and immediate cost when it comes to understanding a codebase, and that's the fundamental sin when it comes to maintainability.
Not true. The benefits of IOC and loose coupling are well established and I won't even bother relisting them here. I'll only say that those benefits usually outweigh the minor indirection you get as a result.
The question then becomes, do you loose couple from the beginning? or tightly couple everywhere and loosen as you go. I tend towards doing it from the start, for consistency sake (help those poor junior engineers out), and for all the other benefits (which again, i don't want to list, but i'll add the one biggie - unit and integration testing).
I'll also note that IOC/loose coupling is rarely the cause of the over abstraction you fear. I've seen it way more in domain models or APIs.
There are many fads, new fangled doo-dads, thingamajigs in our software world. New frameworks, seismic shifts in architecture, herd mentality etc. IOC/loose coupling isn't one of them. It's good, old engineering practice.
For the most part, constructor logic that simply sets dependent fields (and, importantly, does not do anything) along with not requiring fancy initialization shakeups goes a long way to easily testable things.
That said, people that go out of their way to make a fixture for each individual method can be just as annoying as the folks that only do end to end testing.
Basically, life is easy to argue at the strawman level. Breaks down quickly when there are "boots on the ground." (Or whatever other saying works.)
One of the things I like about our environment is that we're always disrupting ourselves (maybe a little too much). So while it's true that there are many heavy weight DI tools (Castle.Windsor for ex), there are also lighter-weight ones (Ninject and co). At the end of the day, we each pick our heavy we want our tools to be (we can always implement IOC without a DI tool if you want to keep things bare boned).
> Basically, life is easy to argue at the strawman level. Breaks down quickly when there are "boots on the ground." (Or whatever other saying works.)
It's an interesting discussion for sure. And some of it is philosophical. When the "rubber hits the road" (or whatever saying works) and i'm stuck in the office at 2am trying refactor a ball of mud, I wish those who came before me had thought of this stuff.
Engineering. Not Programming.
Especially the last part is critical - if there is no abstraction or some other kind of tight binding between components you've already lost.
Deciding that mentioning classes anywhere in a signature is Evil, and that everything must implement a separately-defined interface in the Java/C# sense, is IMHO not at all useful. Not because it's "expensive" - it just takes an already-designed class API and copypastes it into an interface - but it's pure busywork. More code (which some people treat as a plus), harder to understand (because now there's always extra ambiguity at a call site as to which implementation is being called), adds nothing. This type of thinking - taking a useful tool or rule of thumb and turning it into dogma - seems far more prevalent in Java-land than in any other language I've worked in; I've never really understood why.
Your first priority is to design your code for the inevitable day when your successor (or perhaps you yourself) dislikes it. The less effort/risk to uncouple and remove it, the better.
This helps avoid the well-meaning pitfall where you design things to be "customizable in the future", but the ways it needs to be customized aren't well known, and it turns out you've only complexity that gets in the way of the changes you eventually want.
In contrast, any code that's easy to remove implies that there's a good boundary, interface, or contract.
I’ve found it’s often easier to keep a few broader ideas in mind.
Funny, OP doesn't mention the broadest idea of all: ego.
I've worked with hundreds of programmers and have sensed an inverse correlation between experience and openness to suggestions about maintainable code. I believe that the main reason is not that the more experienced developers are convinced that their methods are that much better (after all, there are often many acceptable ways), but that their ego won't let them "lose a debate".
When I return peer reviewed code to a young developer with suggestions (and reasoning!) about how to make it more maintainable, I often get a big thank you. The same feedback often gets a debate from a more experienced developer.
On the other hand, when someone gives me feedback and suggestions, my first reaction is to engage them and explain why they're wrong and I'm right. But as soon as I set my ego aside and think of the code instead of myself, I open myself up to learning. This is vital because what I don't know is always much, much more than what I do.
Maybe that's because they have the experience they are able to debate it!
I think "maintainable" will always be debatable, because we are not using scientific methods to determine what is more maintainable. So we are all subjects to subtle biases like confirmation bias.
It would be great if we could use scientific method to determine how to write more maintainable code, but you would need a good definition of "maintainable" first, and that's hard.
Personally, I don't see much, if any, hard evidence that today's code (in 30 years to the future) will be more maintainable than the code written 30 years ago. I work on 30+ years old legacy codebase, which is often crazy complicated and effectively not-refactorable. But you can still fix bugs, you can add functionality. It's expensive to do, but not impossible. Would be the accumulated cost lower if people spent more time refactoring it, like today sometimes happens? Not clear at all.
(Also see "The Wrong Abstraction": https://www.sandimetz.com/blog/2016/1/20/the-wrong-abstracti...)
Tomorrow's oddball might not happen, after all.
I went from a big tech co where we had the resources to abstract and deliver ideal solutions, to an existential startup that is hacking features together to win contracts. My biggest frustration is new engineers joining and overcomplicating simple tasks by trying to create generic solutions to every single feature request we get. The worst is that this comes across as 'investment' to management but it hardly ever bears fruit and we're just slowing down delivery to massage the developer's anxieties about technical debt. The reality is that the product may not really need that feature, or the customer may never use it, or it's good-enough, etc. I think in this environment we should ship simple features, that are hacky, until we gather enough data about it's value and use.
If all you ever going to deal with is humans in your class/API, there is no need to make it generic enough to work with all animals. The Clever Boy in all of us probably wants to implement this bit of "future proofing investment" but we need to resist the temptation.
I wish STTCPW (Simplest Thing That Could Possibly Work) made for a better acronym. Also related: KISS (which I prefer to interpret as "Keep It Stupid-Simple").
1. Make the simplest incremental change to the existing system to satisfy requirements, or
2. Make the simplest overall system which satisfies requirements?
The first is fine for prototyping when you're moving fast and breaking things, but guarantees technical debt out the wazoo over the longer term. The second makes for much better code but you may waste a lot of time on refactoring churn if you do too much of it before requirements have really stabilized.
Usually considering the parts of abstraction that will reduce coupling and make understanding easier, or allow making an implementation high performance or secure. You know, things nobody really states in requirements but everyone wants in the end. Most of the time internationalization and localization are also needed but not stated outright.
Also consider things that are essentially impossible to bolt on later or inordinately expensive.
Well that assumes the code is the problem and not the programmer. The code might be perfectly maintainable but the programmer lack skills to do just that.
I've only seen very rarely really-hard-to-maintain code, and there were of two sorts:
1) over-engineered pseudo-senior object-oriented spaghetti plate -- generally resulting from methods that supposedly make the code more maintainable.
2) logically-wrong over logically-wrong spaghetti plate -- the kind of code that programmers hate to admit they do: simply logically-wrong code.
On the other hand, I've seen plenty of beginners making critics about a code that is supposely hard-to-maintain whereas the problem was them because:
1) they don't know how to read and learn a code base. They typically start working on a projets and they don't read the codebase. They jump on a task. They actually never read the codebase, but only piece of it.
2) they think the code is "obfuscated" whereas it is, well, just not. The programmer is just a beginner that needs to learn how to read a boolen expression for example (typically: var ok = cond1 && !cond2 || cond3 -- omg the code is obfuscated!).
I now see that it's not about doing things minimally, but doing things as correctly and clearly as possible. And one should never be afraid to refactor!
(Obviously there are cases where single line changes are the correct solution; but back then my mindset was "how can I do this with as little change as possible" and it should have been "as clearly as possible")
Refactoring should be as simple as possible.
I've just finished a contract where we had to deal with legacy code on iOS and Android (yes, it exists. Let two junior programmers not very keen to learn from the outside during 5 years working on the app and see with what you end up).
Refactoring the Android code to a healthy state was greatly facilitated by Android Studio and the whole IntelliJ suite. We were not afraid to move things around and could always see the light at the end of the tunnel.
On the other side, XCode has been a pain in the ass for the whole project. We had ObjC and Swift coode. Good luck with that. Even the pure Swift was hard to refactor. SourceKit is great and should help to get a top notch IDE that can resonate about source code but somehow Apple does not seem to care about the tools.
Sorry, I get passionate when talking about refactoring and tools.
Another example is how the Pharo Project (http://pharo.org/) is refactoring the Squeak source (http://squeak.org/) code/architecture. They developed custom tools for this purpose and their code cleaning is quite impressive when you know where they are coming from.
You take a very simple functional language, like say, Haskell Core. Let's consider a large and representative enough (finite) set of programs in this language. We find the smallest possible (or very small) representation of this set. This will involve definition of some commonly used functions. Let's call all the functions that are in this representation "the basic library".
Then, for a new program, we can try to find shortest possible (among those you're able to consider) representation, such that, using the functions from the basic library comes for free (so you don't count the size of functions in the basic library towards the total size of the new program).
So if we can write a program that would be able to find smaller representation using definitions (functions) that we already generated in that representative set, I think it would essentially automatically refactor code to be more readable and maintainable.
There's often a trade off between coupling and terseness which requires (human) judgement in order to balance the competing concerns correctly. There's also the matter of using appropriate metaphors.
I have a feeling that a "cleaner" optimized like this would get you a program full of the equivalent of perl one liners, assuming it's even possible.
Proper metaphors (better call them abstractions) would be generated at that "basic library" creation step. Basically those are functions that are commonly useful. Some of them would correspond to abstractions known by many programmers, some of them would be different, but altogether would let you write more compact code (because that's what they have been selected for). Ultimately, knowing abstractions amortize and ease understanding of the code that uses those.
We humans do the same thing - the code that is easy to understand is the code that uses commonly known abstractions, such as control structures, standard library functions, etc. What makes this possible is that we hold these shared abstractions in our heads, and create them as a part of programming culture by working with different code bases.
There is no trade off. You cannot write a terse function with high coupling, because then you cannot use the shared abstractions very efficiently inside it, nor you can build other functions easily by reusing this function. So terseness IMHO behaves very differently when you account for the fact that you can create new definitions, and you put those definitions to use. (And especially if those definitions come from some "basic library" for free - they are not included in the cost that you have to pay to understand the program - because they have already been understood.)
Moreover, the "basic library" creation step that you allude to the point where you decide where the borders between different modules of code lie. Creating those borders at the appropriate point - deciding the coupling - is arguably the most important part of refactoring and the part (I) have found hardest to clean up in legacy code. I am currently rewriting a library which in retrospect I now realize should have been two libraries.
"You cannot write a terse function with high coupling"
Oh, you absolutely can. In fact, after a certain point terseness tends to correlate with higher coupling. There is often a trade off to be made between coupling one block of code to another (e.g. introducing a dependency) and sacrificing terseness.
How is that different from a function (or set of functions) that abstract the technicalities of sending messages over SMTP?
I am not sure why specific metaphor is so helpful here. I don't think it helps in understanding the function, on the contrary.
> Moreover, the "basic library" creation step that you allude to the point where you decide where the borders between different modules of code lie.
Not quite. The important thing to understand is that I am not optimizing for size of a single program, but large set of different programs. The same thing that humans do.
> There is often a trade off to be made between coupling one block of code to another (e.g. introducing a dependency) and sacrificing terseness.
Well, I understand "high coupling" differently than you, then. For me, it means things like doing different unrelated things in a single function (so the result is that it's harder to reuse it). It doesn't mean that you cannot call other functions.
I don't consider a function that avoids a library call, instead inlining the functionality, to be well-written. (If that's what you mean by the trade-off - maybe some example would help.)
It isn't a different function, but "send_email" is better than "send_message" which is in turn better than a function just called "x" which is shorter. In each case a different metaphor is used. x is a shorter method but code that uses names like that everywhere is NOT cleaner. It's horrible.
>Not quite. The important thing to understand is that I am not optimizing for size of a single program, but large set of different programs.
Optimizing purely for size will end up increasing coupling to an insane degree. I've seen this done by people who get religious about DRY.
>Well, I understand "high coupling" differently than you, then. For me, it means things like doing different unrelated things in a single function (so the result is that it's harder to reuse it). It doesn't mean that you cannot call other functions.
If you're using one function to call another you have coupled them.
Coupling is not about what you can or cannot do it's about what is.
Coherence (or rather, a lack thereof) is about doing different things in the same function (or class, file, project, etc.).
OK, I get your point about coupling. But I am not sure why you consider high coupling to be a bad thing, especially in case where you have an automated refactoring system.
It seems to me that to "decrease coupling" you have to add nodes to dependency graph, and this increases overall complexity.
Perhaps you could provide some example where high coupling (calling different functions from a single function) is bad, and how would you like to have it resolved.
Update: Actually, with automated refactoring, you wouldn't have to modify any existing functions (except maybe to call your new functions). You would just write new functions that would incorporate the new functionality using perhaps the existing building blocks. Then the refactorer would sort out things in DRY manner, as needed. So you don't need to care if the code is easy to refactor, it just needs to be easy to read.
Isolation of code enables you to more easily understand it, test it, replace it and re-use it.
Linear increases in coupling leads to a combinatorial explosion in the difficulty in all of the above.
Coupling is actually more important than DRY.
I don't understand how you imagine you can "isolate code" while making it less DRY. If I need to do certain functionality from some function, I need either to call other function to do it (if you already have a function that can do it) or copy that functionality into the function itself. The first approach is DRY, the second isn't. But I don't think second approach is a good idea, like, at all. So if I assume you don't mean that, what do you mean?
I can understand you can make code easier to test if you make it less DRY. Let's put that aside, because it's not really a big deal in this discussion. But the other three - I would love to see some specific example where making code less DRY will increase ease to understand it, and also possibly replace it and re-use it (provided we don't care about elegance of the result, since it would be taken care of by subsequent refactoring, as I already explained, so we can for instance copy-paste the existing code for the purpose of change).
It would help you formalize parts of the typically non-formal programming, allowing automated or semi-automated formal verification.
The language would allow you to bypass accidental hard restrictions imposed by type system - essentially duck typed but only if formal requirements are met on the types.
Hey, I've described Isabelle/HOL; another language that is pretty close but more typical looking is Pyret.
Mind you, this is also in part due to traceability; I want to be able to point to a PR and go 'this is where this functionality was changed', with all the details. That makes it easier for the reviewer too, easier to find and review the actual change if it's the only change in a PR than when it's hidden in the diff alongside a major refactoring.
I've also seen another open source project that said "please, no refactoring PRs". I can see the value in that one too - code is never perfect, and you can continuously polish but it won't actually achieve much, besides causing a lot of churn in your codebase.
You have described a strategy to avoid having to deal with bad maintainability, not a strategy to avoid creating bad maintainability.
1. I see "unmaintainable" as mostly a factor of time (or age of the codebase), of changing requirements and people working on the code. So giving advice on how to write the code to be maintainable isn't gonna help much. If people turnover quickly, they won't bother, if they turnover slower, they will do their best to keep it maintainable and yet fail.
2. People who follow (intentionally or not) the advice I gave only ever see maintainable code, so from their perspective, it leads to (having to deal with) maintainable code.
3. (= 1. + 2.) Sadly, the people who are the most displeased with the legacy code not being maintainable are the ones who tend to leave quickly (or write something on their own), paradoxically, compounding to the problem of the unmaintainable code in the first place!
I don't think there is a good solution to the problem. Maintaining old code is hard work.
(Written by someone who has been doing brownfield projects, often bordering on code archeology, at the same employer for far too many years)
Could it be that a good definition of maintainable is simply "easy to understand by others"? Then, of course, code earlier in the life cycle tends to be easier to understand.
Then there could be a difference between "easy to work with" (by the original author(s)) and "easy to understand by others". But assuming original authors were good programmers, I am not sure how they could cause this difference to occur.
- "Get it done attitude": Declare something as done while it's still a prototype.
- "Fail early": start with prototypes, end with prototypes. ship a prototype to production.
- "Knowing the software business": know how to trick non-technical people into buying overpriced prototypes made of spaghetti.
- "Product driven", "Feature driven", "Customer driven": implement only what can be sold (features), disregard everything else. e.g: security, scalability, etc.
It is becoming a business model to sell prototypes as finished products.
Then some other parts are conveniently misinterpreted. People misunderstand the role of the scrum master and interpret it as the development team leader... confuse points with hours, or try to measure team performance using velocity... which is the same as measuring a truck driver performance by how much gas they're using rather than concrete results.
Everyone claims to be doing scrum correctly, just like everyone claims to be doing git-flow correctly. It's not only cool to be agile and use scrum, it's more like a dogmatic cult where scrum is the ultimate truth that solves every problem in every case and cannot be criticized.
Which is more than we can say for this dismissal.
A strange thing, this.
This statement may be true in language domains the author is used to, but is certainly not true across the full spectrum. Also - there are two sorts of comments: 'what' comments, and 'why' comments.
'What' comments tell you what the code is doing. A lot of the time, in a lot of languages, if you code clearly these are needed only rarely, if at all. They are telling you what the code is doing, mostly as a convenience - they can always be replaced by spending time reading the code to see what it's doing. There are a number of situations where they are useful though, mostly obviously in languages like C or even Assembly where code often cannot be made easily readable. The 'what' comments allow you to quickly parse code to build up understanding of what's going on. Good assembly has a very high comment to code ratio - you are mostly reading the comments rather than the code to grok it. Occasional 'what' comments on a bit of C pointer arithmetic allow you to understand the authors intention and check against that 'specification'. I agree that in more expressive languages, these sort of comments become rarer and rarer.
'Why' comments are irreplacable in every language, and are what the author seems to be referring to in the above quote. "I have chosen to do this like this here because...". Because otherwise X unforseen bug, because otherwise Y performance issue, because Z client requirement. However expressive your code, that extra context is never going to be communicated by the code itself.
I'd encourage turning these sorts of 'comments' into Javadoc-style 'annotations'; they're still comments, so language implementation will ignore them, but that small amount of structure makes it possible for separate tools to perform sanity checks (i.e. obvious combinations of incompatibly-annotated types), or even to show annotations at call sites in an IDE (e.g. when hovering over a name).
How: newton-raphson iteration, 2 rounds
What: return a number within 1% of the positive square root of the input, which must be in a certain range
Why: it's for a rendering, it doesn't need to be precise
In this breakdown the 'what' is what I tend to want the most often, as a reader. It gives an anchor to check the code against, and enables reading that code without having to understand all the other code it calls on -- just the 'what' comments of the other code.
I'd rather see something like this:
Why: We need to calculate distances between widgets to pack them so that it looks nice to the end user, but is also fast - start must not take longer than about 1 second. We are running on embedded hardware.
What: Use square root approximation for distance defined over the range of pixels available on screen.
How: Newton-raphson iteration, 2 rounds, fixed point.
Your experience might be different, and that'd be kind of interesting.
Don't make large classes that implement many features. Split those features/responsibilities into smaller classes and then have a class that calls to the smaller ones.
A good way to think about this is your classes should have as few member variables as possible.
For large systems it's absolutely critical to be careful around special case, at best avoid them. The hard part of dealing with special cases is to explain to business people that their "It's just ONE special case, how bad can it be" will snowball if allowed.
Being a sysadmin, code that I write tends to be in the form of puppet manifests, and these things do apply. There's one more rule of thumb I use to determine how I should approach building systems. Basically, I think that shortcuts are not (usually) worth the trouble.
What I mean by this is that usually the least-effort, fastest (or "obvious") solution to a particular problem is not worth the time saved, when compared to just spending the time figuring out how to do things "properly".
There is a continuous cost-benefit analysis where you keep asking yourself whether you will save you enough time taking the shortcut that potentially having to re-do it later (or fix potential issues caused by it) will still end up less "expensive".
When you do choose to take things slow, though, you usually end up having a better understanding of the problem as well as the solution, which naturally leads to a better result.
Unfortunately the database design is crap leading to way more code than is necessary. It also includes important information that has been pickled and stored in a field, so its a nightmare to debug. It's done in Django but he has used a "fat controllers thin models philosophy" - the opposite of Django best practices, so it doesn't take advantage of many of Django's features such as lazy loading and doesn't run quickly as a result.
I have been going through the code of a well-known document database... I am horrified to discover over 400 classes in their abstractions project, including some very clever stuff, which I then try to find examples of in the rest of the code, only to come up with nothing.
Previous experience can be very misleading. It is safest to come at the code and design as a blank slate. Prediction (also known as educated guesses) is worse than actual understanding, especially deep understanding.
Sometimes being fancy saves a lot of effort down the road. It is being fancy for its own sake or for purely imagined reasons that is dangerous.
They cannot be avoided, but their impact can be reduced. If you end up drowning in them, it means the design is broken and needs to be rethought.
There are only two hard things in computer science: cache invalidation, naming things, and off by one errors.
Laziness Impatience Hubris
Back when I was a Java dev, judiciously following the SOLID principles was a vital part of keeping code maintainable. They're still as true today as they ever were.
* a class should have only a single responsibility
* software entities should be open for extension, but closed for modification
* objects in a program should be replaceable with instances of their subtypes without altering the correctness of that program.
* many client-specific interfaces are better than one general-purpose interface.
* depend upon abstractions, not concretions.
I guess a decent amount of what I'm feeling comes from my current job, which breaks a decent number of those rules. (S, O, I at least, possibly D but I don't think so) But despite that, code generally seems to be pretty easy to read through, so I'm a bit conflicted.
Depending upon abstractions directly is much less reliable than depending upon contract (which is usually not explicit in the abstraction) and preferably also verifying it as both preconditions and postconditions.
The other letters are important, though software should be designed for easy modification instead of being closed to it. Otherwise you end up with abstraction forests and deprecated code.
Many interfaces is typically a smell, as the developer now has to juggle multiple balls in his head. There is good medium ground out there - making the interface maximize usefulness while not sacrificing too much of Liskov substitution capability is key.