I’m pretty sure that I have a knack for designing software systems that make sense. This might be because I have a ridiculously inflated sense of self worth, but I choose to ignore this possibility.
Rather than try to establish any kind of track record or credentials that establish that I am honestly entitled to lecturing about anything, I am going to try to explain what properties I consider to be important in a well-built software system, and take a stab at explaining what benefits are to be had from them. (further disclaimer: I make no claim whatsoever that any of these ideas are the least bit novel)
So, without further delay…
Well-Constructed Software Systems are Object Oriented
I am not going to try to say that building a procedurally-styled system will directly cause a nuclear war with Russia. There are lots of smart folk out there who have figured out a large number of successful ways to build software.
I will, however, say that objects are a super-robust, structured way to parameterize behaviours, and that hiding implementations behind polymorphic interfaces is fantastic for simplifying TDD. I think these things are super-important and I derive oodles of joy from working with code that is built this way. Unless I totally screw this blog post up, the reasoning will become apparent in a few paragraphs.
Well-Constructed Software is Unit Testable
There are people out there who have built amazing software without any automated testing, and there are people who just go for full-on integration tests all the time, and there are even people who do lots of complicated algebra to prove that their application is correct in all cases. In my opinion, the best camp to be in is the one where you carpet your application with a thick nest of unit tests, and throw in integration tests wherever it feels good. (and whenever you fix a bug)
This is a great situation to be in because unit tests are very, very fast, and they are the only kind of automated test that you can really count on to be 100% reliable. Further, once you get good at them, you can punt your slow, unreliable (but oh-so-handy) functional tests on some build slaves somewhere else, so they do not intrude upon your continuous integration/deployment/stuff.
Well-Constructed Software Systems Mostly Form a Directed Acyclic Graph
The previous point was actually sort of a precursor to this one. Objects and interfaces were exciting 20 years ago maybe, but we are in the distant future (the year 2000!) now and that means that OO is passe.
A much more interesting and useful claim to make, I think, is that the references your object system has should mostly form a DAG. What does that mean? In short, it means that you have classes “at the top” of your application (you might have one called Application or MainWindow, to pick an example), and, from there, the references mostly trickle “down” to implementation details like file handles, sockets, and textures.
More to the point, it means that your “down” classes do not hold references that go “up”. If you show me a design that requires that your Texture class hold a reference to its Application, my instinct will be that something is not right.
Further, it means that you really want to avoid having “sibling” objects that form a circular relationship. Thar be dragons. And no, I’m completely incapable of articulating why. If cornered, about the best I can do is mumble some incoherent bullshit about how it offends my senses and how it will, at the least, throw more than a few garbage collection implementations for a loop. (which, I should point out, can become particularly serious if the cycle spans different programming runtimes, like Python and C++)
This is a great property for a software system to have because you can test your “lower” types without even needing to construct the “higher” ones at all. This means that individual unit tests need to execute less code, which means easier maintenance, higher orthogonality, and faster tests.
There’s a catch with this one, of course. When I say “mostly,” I really do mean mostly. All too often, you’re going to be in some situation where you really need to attach a listener or a lambda or whatever. Don’t sweat it, but you really want to take a closer look at those upward-pointing references. You might want to make them weak references instead of strong, and, if you’re coding in a statically-typed language, you almost certainly want that reference to have an interface type, rather than a concrete class. Sometimes, you can even clip those references entirely by leveraging method return values.
Well-Constructed Software Systems Do Not Depend on Global State
Remember the bit about the Directed Acyclic Graph? Global state is like having an object that is referred to by every other object and function in your entire application. It’s a ridiculously bad idea.
That being said, I periodically break this all the time because I’m lazy as hell and I’m always so sure that I’ll get away clean this time. If there’s anything I love to cut, it’s corners. Most of the time, I hurt dearly for my transgressions. I fail to learn my lesson because I’m retarded.
The thing is, I really like test-driven development, and if there’s anything that wrecks TDD, it’s unreliable tests. And if there’s anything that makes tests unreliable, it’s state that leaks in from outside the test harness. That’s what global variables are.
It sounds really painful, but what you really should be doing is injecting all your dependencies. In practice, it really is painful, but only until you realize that globe-spanning dependencies are generally a sign that your design has other problems.
What this means is that your objects always work with whatever you give them. If you’re in a test and you give an object fakes, you have pretty good assurance that it’s only going to talk to fakes.
One testing practice I have had great success with is only mocking out the hardware level stuff: Filesystem, network, sound, video, windowing, and so on. These are ultimately the things that make tests slow and frail, so they clearly have no place in my unit tests. Once the hardware stuff is faked out, the other objects in the system cease to pose a threat to testability: are just bits swimming around in memory. When all your dependencies are explicit, formal parameters, this is very easy to accomplish.
There’s a reason for all of these painful rules, and it basically boils down to this:
def testMyCrap(self): hw = FakeHardwareServices() o = MyCrap(hw) self.assertEqual('something', o.doSomething())
Bam. That’s it, right there.
Looks like the toy example everyone uses to introduce TDD, right? Sure, but there’s a trick: I have gone out of my way to ensure that all of my objects accept their dependencies as formal parameters, and I have ensured that none of my objects gank or poke random global objects. I’ve also ensured that none of my objects require ridiculous heavy upstream dependencies to clutter everything up.
I know that I can test every single class in my application from this template, and I can do it in a way that will run instantly and pass 100% of the time.