The More they Overthink the Plumbing…
There are many lines in Star Trek which have stuck with me over the years; recently, it's been this line from Scotty when explaining how he sabotaged a ship that was trying to chase them: The more they overthink the plumbing, the easier it is to gum up the pipes
.
TL;DR for the lazy: XP good, make code as simple as possible, avoid 3rd party libs and APIs, here's why and how we got here.
History
This feels very pertinent to software engineering, where we have been building ever taller towers of abstraction, many of which make things worse.
The very beginning
At the base, computers are electronics, complex circuits with voltages propagating through them. They're not even really digital electronics — transistors are fundamentally analog devices, that can be used as amplifiers, it's just that if you abstract things in the right way, you can treat it as "close enough" to discrete on-or-off to perform Boolean logic. This is genuinely useful, because while it is possible to perform computations in the analog domain, it's really hard to do this with sufficient reliability for anything complex… but even so, we may well find that very large neural networks are somewhat resilient to these imperfections just as our messy wet chemical brains are somewhat resilient to all the things going on in and around them.
Next level up, Boolean logic. On-vs.-off, 1-vs.-0, isn't enough, so we bunch several together to make base-2 numbers. That's great, and lets us represent natural integers, which themselves can be treated as if they were letters or symbols by way of a simple look-up table (which is basically how ASCII and its early competitors happened), or instructions for the CPU to follow. A few extensions also allow you to do negative numbers or floating point and decimal numbers (related but subtly different). All good stuff, though you do sometimes surprise people with 1/10th being a recurring fraction in base-2, so you shouldn't use this system for anything involving money.
Structures
Next level up, structures. Arrays are one of the foundational examples — you have a sequence of some length, call it n
, that you're treating conceptually as a single thing. They might be numbers (income in each day) or they might be letters (to form a string of text). There were two common ways to do this when I was a kid: (1) to begin with a number saying how long this list is, followed by that many items; (2) to begin with the content, and to assume the list has a subsequent item until you encounter a special symbol that says "stop". Given my experience in Mac OS classic, the former was called a "Pascal string" and the latter a "C string", named after what the Pascal and C languages did.
Each of these approaches had weaknesses: C strings often didn't get their final "terminating" character created or checked leading to corruption of system memory; while Pascal strings had to have their size known in advance and thus led to using too much memory (by 1980s and 1990s standards where my first two home computers came with 64 kB and 8 MB of RAM respectively), and/or leaving them unable to cope with long bits of text (which is still a problem for some combinations of names and forms of national ID). There are ways around this with more complex structures such as linked lists and trees — I'm not actually sure how any given modern language handles strings internally, in part because they may have more than one string-like data structure and each may do things differently to give different performance trade-offs.
The structures can get more complex. Before I go down that path too far, I will also say that simple concepts like "time" and "a string of letters" can each be far more complex than it first seems: time comes with time zones, which can vary with little warning, and leap-seconds due to the rotation of the planet varying very slightly from one year to the next; and at the speed current computers operate at, I've even seen people care about light cones (to determine if a stock transaction on an exchange was suspicious do to it being created before information reached it from some other exchange) and GPS famously requires accounting for time dilation due to time dilation from both special relativity (speed-related) and general relativity (gravitational potential related). Strings aren't much better, as the original approach was to match what the keyboards on teleprinters used (with separate "newline" (\n
) and "carriage return" (\r
) symbols, where Mac OS classic ended each line of text with \r>
, Unix (and modern Mac OS)with \n
, and Windows with \r\n
, and several other choices for systems you might have never heard of. And then we noticed that most of the world, even most of Europe, doesn't use just the symbols on a US/UK keyboard, and came up with a bunch of different encodings before finally settling on Unicode… which itself took a few attempts to find a representation everyone could agree on which (for the moment) is utf-8 — a variable length system which would be a bit much to describe here, but put simply: if you want to reverse a string, in ASCII you just reverse the sequence of bytes and you're done, but if you do that in UTF-8 you probably no longer have something that's even possible to display; even if you reverse the "code points" (the next layer of abstraction up) you're probably doing it wrong, as that switches the flags for Canada (CA) and the Ascension Islands (AC).
Algorithms and control flow
Structures were great, and did a lot for us. Indeed, we still find them all over the place as a fundamental concept. At this point I should divert from the data to the algorithms, as they too were given layers of abstraction: initially, algorithms were fixed as circuits; then we found ways to make circuits change other circuits, first mechanically (by rotating a drum with wires on them) and then electrically (with valves, relays, and transistors). The Turing machine was a formalisation of this abstraction.
Load instruction, execute (perform) instruction; load, execute; repeat. Nice and easy. Conditional instructions? A branch
command: if ${some condition is true}
then the next instruction you should load is at memory address ${xyz}
, otherwise continue as normal. Nice and easy, good abstraction, the if
statement is still with us. Loops?
#marker load, execute load, execute load, execute if not finished, go to #marker
Nice and easy, that's a do-while
loop; there's several others I won't bore you with, they're still fairly common.
Next level of abstraction: functions. If you're doing one thing often, you probably only want to write it once and then re-use it in many places. We did this by borrowing an idea from maths, called a function. A function takes "arguments" as inputs, and then returns a value. Good stuff, nice and easy. Each instruction on a basic CPU is a little function, and more complex CPUs have "microcode" which is basically some built-in functions. Good stuff.
Objects
Why this diversion from data to processing? Because the next level of abstraction combines them both. "Object-oriented" code takes some data structure and combines it with the functions that are good for that data structure into a "class": for example, you might have a String
class which has functions — in this context generally called "methods" — which perform operations on that string such as "make this uppercase" or "reverse this"; this is very useful, because it means a user of this doesn't need to think too hard about the underlying data structure — to go back to the previous point about the change from ASCII to UFT-8, I simply don't need to care if a modern String
is using UTF-8 or ASCII, nor do I need to care about the rules for multiple bytes into code points, nor do I need to care about the rules for combining multiple code points into a single printable character, if I want to reverse a string, I can look for an existing reverse()
method and I'm done.
They're really neat and useful. Indeed, objects were really popular for a long time, and although they have recently fallen out of fashion they are still widely available in modern languages. It will take a few more layers of abstraction to really say why they're no longer fashionable.
Next layer of abstraction: subclassing. Classes make it easier to divide code into logically relevant areas — string things in the string class, number things in a number class, network things in a network class, and so on. But things often also form hierarchies — say you're making an action video game in this paradigm (modern game development favours a different approach), and you want all the entities in your game to have a number of hitpoints, and that if depleted they will be destroyed. Great, so do you copy-paste a bunch of hitpoints
property and a hitWith(damage)
function? No! You have a parent class GameEntity
with those things, and all your game's entities — chests/crates, the player, the enemies, exploding barrels — are made to "inherit" from this. Nice. Makes things logical and easy… except when it doesn't.
There are three big ways for this to cause complexity, the first two are easy to explain even though they can be a pain to resolve: multiple inheritance (if you've got two "parent" classes with a method of the same name, which one does the child class inherit from?); unclear hierarchies (is a square a special type of rectangle where the orthogonal sides are equal, or is a rectangle a special type of square where the sides can be different lengths?).
The third problem is typing — and no, I don't mean in the sense of what my fingers are currently doing on the keyboard. Each class (and structure, and also the "primitive" types the CPU uses such as "16 bit unsigned integer" and "64 bit floating point number") can be said to be "types", and each function and method will expect specific types on both input and output. But if you have a collection of GameEntity
objects, you need something a bit more general, as any single GameEntity
might be a Player
or a Crate
or a Enemy
or an ExplodingBarrel
, so you need a way to operate on both the higher-level concept of a GameEntity
without erasing all the information about the specific detailed type. Unfortunately, this description just gave you the example where it's fairly easy, and everyone can see what's going on. This is very useful, and sometimes even mandatory — we want to have a generalised concept of a collection of objects without having to make special cases for both integers
and GameEntity
— the problem is that it's almost impossible to gauge the correct level of abstraction: often we either spend a huge effort to make something worth with any possible type when in practice there will be exactly one (it happens!) or we assume it will always be one of a small set of possible classes and make it too narrow.
These issues, combined with a few other more subtle issues, have led modern development to favour composition over inheritance: where related things are combined within some structure (or class) rather than inherited from a parent class — a Player
(etc.) done this way would indeed have a property named "hitpoints", but that hitpoints
property would be a class (rather than a primitive number) which would handle all the complexities and hide it from the Player
.
Design patterns
Next level of abstraction: Design patterns. These come at two levels, code — for example, how are new objects created, how is the data presented to a different system expecting a specific format, etc. — and architectural. There's a lot of different ways to do this, each better or worse suited for a specific task, but you can imagine this as filling a similar role to classes, but one layer up: a good way to organise the code, all the things related to one task are found in one place. When I began iOS development, the standard architecture for apps was MVC: Model View Controller. The model is the data (perhaps a database on your device, perhaps a connection to an API on the internet); the View is your actual user interface; the Controller is the bit which glues the two together.
And this is where I get confused.
Apparently, lots of developers kept putting "too much" into the Controller.
(As an aside: I'm genuinely not sure what "too much" means — I've worked with code that clearly hit this category as there were 1,000 lines inside a single if-statement; but I've also seen some developers hold the opinion that anything more than 3 lines of code in a method is too many lines).
Too much into the Controller? The glue which combines the model with the view? OK, lets say this is so: what's the solution? There are several, because of course there are. Apple's replacement for MVC is MVVM: Model-View-ViewModel, where the ViewModel is a transformation of the Model to be specifically what the View needs to know about, and the glue (that was formerly the point of the Controller) is supposed to be automated. (This automation is what "Reactive" means in the context of UI development).
Don't get me wrong: if automation can be made to work, that's good. It's the point of computers, it's why they're everywhere. Even though the magic words to bind a value to a view are hard to remember, this is not the problem.
There are two problems here, one is fundamental, the other purely in how the magic binding works. The fundamental problem is now we have both an automated connection (View-ViewModel) and a manual connection (Model-ViewModel). No, just no. This gives us all the downsides of both. The implementation problem is that all this magic binding requires a lot of behind-the-scenes complexity of exactly the same kind which led to classes and inheritance becoming huge headaches that we all moved away from as much as possible (this also impacts web development, where Meta's React.js framework seems to be very popular).
Testing
Even the problems with magic bindings hasn't dissuaded people from going further. However, the next level of complexity needs another diversion, this time to testing.
Testing is hard. It's very easy to write code and think "that looks fine", when it isn't. Even getting humans to try to use your code only reveals some of the issues, though even this is much better than just taking a developer's word for it. One of the things we do to account for this difficulty is automated tests. You'll spot the first problem immediately: we write programs to test programs. How do we know the tests are correct? We don't.
There's one school of thought which says we should write the tests first, before any other code. Running these tests must produce at least one fail, because the code does nothing; then you add code until the tests pass. This mindset is hard to get into.
Another school of thought is that we should aim to cover 100% of our code with tests. I prefer this one over writing the tests first and the code later, partly because of how I approach development in general (I develop an understanding of the problem as I write the code), and partly because I think this is a tighter constraint than writing the tests first (there are probably more possible tests than you can immediately think of).
Now, why is this relevant? Well: how do you architect those tests? If you want to test a payment system, you don't want every single run of every single test of your system to end up triggering a real payment — these things should run multiple times per day per developer, more likely multiple times per hour per developer.
This leads to breaking down methods and functions into smaller and smaller units that do less and less stuff, and which "inject" the things they depend on as arguments. (This is called "dependency injection"). Dependency injection is a nice solution to an abstraction which isn't nice but is kinda necessary.
Baroque architectures
Now it's time to fold this diversion back into the previous topic of architectural design patterns: that some of these tests are better done with very narrow scope has led to people saying that all code needs to be architected with that kind of division in mind. As I wrote earlier, I've seen some developers hold that more than 3 lines of code in a function is too many lines — it seems absurd, but this is why they call for it. Unfortunately, it doesn't just seem absurd, it is absurd: breaking things down to that level means that there's more glue than function, and the glue is harder to test, is harder to follow and understand, and is harder to change.
For a specific example of this, consider the VIPER architectural design patterns:
- V: View
- I: Interactor, provides model ("entity") data relevant to the presenter, along with any methods for manipulating the data
- P: Presenter, prepares the model for display, forwards user input to router or Interactor
- E: Entity, the data itself
- R: Router, to allow the UI to transition between different views
This is too many parts, and the divisions don't represent the real logical splits: The Presenter and the Interactor both alter data, but are not themselves part of the data model; the Presenter is taking on two responsibilities but then forwarding both to other units; the Router is doing purely view-related tasks and could be part of the View (for example, when using Xcode to make UIKit/Interface Builder based apps, this would be a "segue"); and the Interactor is doing things to the Entity which should all be methods on the Entity itself in any language which supports this — it's the fundamental thing which defines the "Object" in "Object Oriented programming".
Analytics
And one final, extra, point: Analytics. You all know the annoying popups for consent to track you. Why do companies bother with them, when all users hate them? Because analytics reveal what users do, which parts of the app they engage with, what they have difficulty with — something you may not know as a normal user, is that they can easily track how far you scroll down the screen, which buttons you tap on, and exactly where your finger (or mouse) pressed (or clicked) on the screen. This is sold as "helping developers discover when buttons are too small to click on" and similar. That said, even though I am a developer, I am just as confused as everyone else about why on earth some websites have 1200 "partners" to "help" them analyse all this.
This website you're reading now (unless someone copy-pasted it), didn't show you a pop-up. This is due to the simple trick of… not tracking anything. I have no idea how many of you are reading this. I don't know your age demographics (which YouTube apparently provides, but I've not looked into that despite having a YouTube account). I don't know, or care, how far you scrolled. I don't know which countries you're all from, which my previous blogging platform, wordpress, told me.
But at the time of writing, this attitude seems to be rare: me, some other random bloggers, HackerNews, and possibly GitHub.
The Problem
It's all just too much: there's far too many things in software for any single developer to know it all; because it can't be known, we keep re-inventing the wheel, and each re-invented wheel has to re-learn the lessons of all the predecessors; this happens at multiple levels of abstraction all at the same time, so we get web frameworks that use JavaScript to handle loading of links and the display of images even though those things are built into the pre-JavaScript standard of HTML; and we have websites with so much excess stuff bundled with them that users would genuinely be better off if the site was a static image.
The thing is, I've seen complaints like this from day one:
- The "Software Crisis" of the 1960s and 1970s, where software projects were over-budget, late, inefficient, filled with bugs, often did not meet requirements, unmanageable and code difficult to maintain, and sometimes just never in a deliverable state. The extra abstractions since then are, in a big way, (partial) solutions to these issues. (We also saw changes to how projects are managed, the scope of which is outside my experience: I've only ever known "agile").
- "No compiler is as efficient as hand-crafted assembly", which was actually true when I first encountered it.
- "C++ takes ages to compile", can be true if you're not careful, thanks to it using template metaprogramming as the mechanism for allowing generic types, such as a generic list whose elements may be of type
int
orGameEntity
or whatever, and that this approach happens to be Turing complete and therefore you can't always tell in advance if the process will even stop. - "Object oriented code is bad because of the complex class hierarchies!" They say, pointing at examples such as
WSSecurity11WSTrustFebruary2005WSSecureConversationFebruary2005WSSecurityPolicy11BasicSecurityProfile10MessageSecurityVersion
which is more the fault of a corporate policy on names than object oriented programming itself.
So, am I just being an old man shaking their fist at a cloud? I think it all comes down to the outcomes: If the code is performant, if it's cheap to produce, if it's not prone to errors, does it really matter what's going on inside? The problem is only if one or more of those are absent or insufficient.
Well, looking at the apps and services around me, I don't think we have a fantastic state of affairs in any of those things:
- User experiences in modern systems are measurably slower today than the 1980s despite modern systems being 6 orders of magnitude more computing power. This is directly related to the abstractions we find useful.
- Apps are more expensive than ever, judging by the size of dev teams and salaries.
- Apps are as buggy as ever, judging by how many App Store update notes are some variant of "bug fixes" (possibly with cringe-inducing attempt at cute phrasing).
Now, one might argue these are unavoidable costs created by "doing it right". When I was at university, there was "programming in the large vs. programming in the small" (when Java was popular despite having exactly the complex class names and hierarchies previously mentioned) was analogised with the difference between building a bridge with a few planks of wood vs. building a suspension bridge — A suspension bridge over a small ditch looks silly, a few planks of wood over the Golden Gate won't even support itself let alone traffic.
Likewise from the perspective of user expectations, there have been many changes in the transition from software of the 1990s, where you buy software once from a physical store, it runs on one machine which has one user, there are no security requirements, and you get to print system requirements on the side of the box it's sold in and it's the user's problem not the developers if the user doesn't have a 640x480 monitor that supports 256 colours, and it will be used in an office with constant lighting; to today, where you're expected to ship regular updates to justify the monthly subscription plan you're selling it on, and the users have a huge range of display sizes and orientations which are expected to be able to change dynamically during use, and the app has to switch between bright and dark modes depending on the time of day, and we have rules requiring support of people with various disabilities.
The converse point is another anecdote from university, when we were doing practice interview questions, and I was forced to justify what I meant by having written "committed to quality"; the me from 20 years ago just thought this meant "be as good as possible", but the pannelist (who had actual experience hiring people) had a better model: his example was to ask me:
"Which is better, a Porche or a go-kart?"
"The Porche, obviously"
"But what if you're a kid who just wants to get down a hill? You don't have a driving licence or even lessons, you're definitely not insured, you just want to have fun. You can't — don't know how and are not allowed — to use the Porche. Now? Now the best is the go-kart".
(All paraphrased, of course. Couldn't afford to record these sessions back in 2003 or so).
What to change?
Don't throw the baby out with the bathwater. Be mindful of Chesterton's fence.
The fences were made for a reason: software is easy to do wrong. The bathwater isn't the mere existence of abstractions, it's that there are too many at every level, and that even when they are very useful in specific cases, they are cargo-culted as silver bullets.
You aren't gonna need it
You, specifically, almost certainly don't need:
- A fancy design pattern such as VIPER. Sure, it does the things it says it does, but it's slow to develop for, and leads to more glue code than business logic. Instead, use the simplest architecture for your platform — for UIKit that would be MVC, for SwiftUI it would be MVVM, for a game I'm told that's ECS.
- Third-party libraries (and, unless you're able to get a service-level agreement whose terms you agree with, third-party APIs). Not even one: sure, theoretically there are some exceptions, but treat each library you depend on like you would a 3rd party vendor who is charging you the equivalent of 10 hours a week of dev time only the bill comes due at the most annoying time possible and isn't nicely spread over the year — these libraries might even do what you want them to do, but you're now responsible for any bugs, security issues, incompatibility with the next version of the system you support, and when the library has its own dependencies, each one brings with it the same problems again… and what you wanted probably isn't too hard to do yourself because 80% of the things were already given to you in the operaring system's built-in library. 3rd party libraries are the collateralized debt obligation of technical debt, and to be avoided for similar reasons. This includes AI (and I don't just mean an LLM when I say AI): AI are neat, but unless you're a research organisation with a very unusual task to automate, you don't need one; and worse, they an even bigger problem than normal 3rd party libraries when it comes to you being held responsible for their flaws without having any power to fix them.
- Anything involving the word "reactive". Sure, it works, it does the thing — but the "magic" which is supposed to make life easier can be done incorrectly just as easily as forgetting a very simple code pattern in a non-reactive UI. It is the illusion of useful.
- User analytics. No, really! I'm not using them on this blog as I write this, and you don't need to either. They give you a pretty graph to look at, but Goodhart's law applies: Whenever a measure becomes a target, it stops being a good measure. Put it another way, you care about business goals? How many users click on some button, isn't that. Even when the button happens to be labelled "pay now", you care about the payment, not the button. You do not need the invasive user-tracking that led to the EU creating the GDPR that you're trying to get around because you chose a non-compliant popup that doesn't have a single huge "just no" button. Even if you have a safety-critical component to your system — you make self-driving cars or a successor to Therac-25, you can track the safety issue itself without tracking a user!
- Any "Big Data" solution. The data you have is not "big". A million rows in your database? That's small data; 2 million chess games? Processed 235x faster with command line tools from 1977 and 1973(!) than with Big Data tools like Amazon Elastic Map Reduce and Hadoop. 4 gigabyte AI model? Still not "big", could use my phone for that; Downloading and processing the current text of all pages on the English Wikipedia? One single developer's computer — in the form of a side-project that runs in the background while they do something else; etc. — if it even fits on one machine, it's small data. Don't even try to use a Big Data approach until you can't buy a single hard drive big enough for it.
What to keep?
Based on my experience, the following are all genuinely useful, and must be kept:
- Whichever language is the Schelling point for your domain. iOS? Swift. Web? JavaScript. etc. — all modern languages have enough abstraction to get done all the things you need for whatever your business logic is.
- Both automated and manual tests. The former is like a spell-checker (which won't spot it when you mix up "loose" and "lose" as I often do), the latter like a proof-reader. Automated tests don't cover everything, but they do save the time of the expensive human who helps with what the automation misses.
- Any simple design pattern. Doesn't seem to matter so much which one so long as it is simple. I've seen code with no design pattern (an entire pantheon of god classes, a thousand lines inside an
if
block), and it's almost (but not quite!) as bad as having an over-complex design pattern like VIPER — I assume there's some scale at which VIPER is the right answer, but I've never seen it. - Version control. It is possible to get away without it… if you only have one developer. You don't want to have only one developer.
- Code review. I'm not convinced it really helps improve the code (too many people go "looks good to me!" without analysing the changes deeply enough to spot defects), but it is a low-cost way to spread knowledge between team members.
- An issue tracker. Again, doesn't matter which — I've worked with literal post-it notes on a wall, and this is fine; conversely, while it's popular to hate JIRA, I'm fine with that too. It really doesn't matter to us software developers, so if you're a manager picking one, go for whatever you like.
Has anyone been here before?
Yes! When I was at university, we were taught about "extreme programming":
- Pair programming
- Extensive code review
- Unit testing of all code
- Not programming features until they are actually needed
- Code simplicity and clarity
- Expecting changes in the customer's requirements as time passes and the problem is better understood
- Frequent communication with the customer and among programmers
As with all trendy philosophies, even groups that say they follow it tend to only follow only about half of the things it calls for.
Let's bring back XP. (No, not that one).
Tags: Coding, Opinion, Programming, software engineers, Technology
Categories: Opinion, Professional, Technology