Why Is Software So Hard To Develop?
Last week, I said that I was going to discuss invariants. I've decided to put this topic off for a while because when I started thinking about it, I realized that it was just a small part of a much bigger subject. Invariants are an intellectual tool that we can use to increase our confidence that a program is working as we expect. However, it does not solve all problems, nor is it the only such tool. As I thought about describing invariants — particularly why and when to use them — I realized that it would be worth taking a look both at the bigger picture as a whole and at other aspects of that picture.
A naïve view of software development is that it should be easy: All you do is tell a computer what you want it to do and it does it. However, as anyone with more than a minuscule amount of experience will tell you, this view makes many assumptions that often fail to be correct in practice:
- You are confident that you know what you want the computer to do.
- Your knowledge is correct and complete.
- You can translate that knowledge accurately into code.
- That code does what you think it does.
- The computer itself works the way you expect it to work.
- In addition to doing what you want, your program does nothing that you do not want.
- The program performs well enough for its intended use.
- If the program's output is intended to be approximate, the approximation is close enough.
- The program behaves reasonably when it encounters absurd or malicious input.
The first two of these items amount to understanding the problem. It may seem obvious that you need to understand a problem before you can solve it; but this seeming obviousness will not long survive an encounter with everyday reality. Most of the time we do not understand completely the problems we set out to solve; that understanding becomes less incomplete as we think more carefully about the problems. If we are solving someone else's problems rather than our own, that thinking may lead us to ask whoever's problem it is to clarify part of the problem. However this clarification happens, one of the most important — and difficult — parts of developing software is understanding clearly what that software is supposed to do.
As a simple example, consider the problem of writing a sort program. Such a program usually has two inputs: a sequence of values and a comparison function defined on those values. Its purpose is to rearrange the values in order according to the comparison function. This description leaves several questions unanswered:
- What assumptions can we make about the comparison function? For example, is it safe to assume that if we use it to compare two values, and we compare those same two values again later, we will always get the same result?
- How does the comparison function report its results? For example, is it a three-way function that says "less," "equal," or "greater," or does it return a single yes/no answer?
- If two values are neither less nor greater than each other, does that imply that they are equal? If not, what assumptions are we allowed to make about those values?
- If two values are equal, are we required to maintain their original order? That is, might there be something else about those values that we do not see, but that our caller might see?
- What about two values that are neither less than nor greater than each other, but are not equal? Are we required to maintain the original order of those values?
Our purpose here is not to answer these questions; it is to point out that there may be quite a number of aspects of even a simple problem that do not have obvious answers, at least not at first.
Once we understand the problem, writing the code is often the easiest part. That is not to say that doing so is easy. To see this for yourself, try writing a binary-search function without consulting a textbook. For many programmers, writing such a program is a humbling experience — even though the specification is not all that difficult. Moreover, many programmers who are not humbled by writing a binary-search program are humbled by testing such a program.
Testing, of course, is part of assumption #4. So are the programming techniques, such as defining invariants, that we use as part of reducing in advance how likely our program is to fail our tests. In order to put the idea of invariants in perspective, we must realize that they are only one small part of a much larger problem.
Assumption #5 is a surprisingly important part as well. Any time the hardware does not work properly, this assumption breaks down. Moreover, it can also break down when we write code that goes outside the bounds of what our programming language defines. If we use a dangling pointer, for example, we have no legitimate expectations about what the machine will do; yet it will probably do something. In that sense, whatever it does is wrong — or right — depending on whether we are viewing the situation from the tester's perspective or the machine's perspective.
Assumption #6 is a difficult one, and one that I think poses a knotty problem to advocates of test-driven development (TDD). It is relatively easy to verify that a program produces the results you expect with specific inputs. It is much harder to verify that the program does not do anything else behind its users' backs. As a simple example of this problem, think about how you would test an operating system to verify that no one has surreptitiously installed a keylogger.
Assumptions #7 and #8 address quality of implementation: Does the program produce its results quickly and accurately enough for a world in which people have limited patience and databases do not always correspond exactly to reality? If you are writing a social-media system that you expect to be able to handle 50,000 simultaneous users, how do you verify that it can do so successfully? What if 20,000 of those users are watching television and, the next time a commercial shows up, all use their smartphones to connect to your system at the same time?
Finally, there is assumption #9, which concerns security. A fair number of incidents have reached worldwide news media recently that have to do with people figuring out how to subvert systems that have been in widespread use for years. These incidents alone should be enough to convince you that our industry does not do a very good job of defending against bad guys, creating seemingly outlandish conditions that cause software to misbehave in ways that those bad guys can exploit.
In short, software is hard to develop for many reasons: We must figure out what to do, do it, and ensure that we have done it correctly. Invariants are just one of the many tools that we can bring to bear during this process. Despite being just one out of many tools, invariants are particularly important because they are much more powerful than their seeming simplicity might suggest, and because many programmers do not understand them well. For those reasons, we shall discuss them next; future articles will address some of these other problems.