I think it's one of the top 5 papers every programmer should read. It's a short and easy read that makes one think about the limits of models, the limits of specifications, and the limits of formal verification. After grokking BCS's insights, there seems to be (an unintended) hubris in James Koppel's statement, "I now have two years experience teaching engineers a better understanding of how to [...] make code future-proof" in his framework of removing defects from "Level 3" design.
(As a side note, there's also a cosmic irony as that paper starts with an anecdote about the "correctness" of detecting ballistic missiles in 1960 given the recent false alarm in Hawaii.)
[1] https://www.student.cs.uwaterloo.ca/~cs492/11public_html/p18...
When a military general or Secretary of Defense asks if the 1960s missile warning system is working "correctly", it means it's working incorrectly when the moon is categorized as an incoming enemy attack.
Likewise, when a general asks if the 2018 warning system is working "correctly", it means the system is designed incorrectly if the pixels of text on the screen[1] lead operators to trigger the unintended action.
Categorizing one error as a "radar error" and another as a "UI error" as if they are 2 disconnected concepts of correctness is missing the point of BCS's essay.
When people think of something working "correctly", they want the whole system to work correctly. They will not explicitly enumerate all the subcomponents nor all the subdisciplines such as "UI design".
That it was even possible is to me quite damning of project oversight, for allowing the system to get to that stage with such inadequate procedural and UI safeguards.
Apart from that, software either has some functionality or it does not. It's actually not particularly meaningful to discuss whether or not software was originally supposed to have functionality, unless you just want to finger point. There may be a bug that stops the functionality from working correctly or it may be that you got the requirements wrong and implemented the wrong thing, or it may be that you never even tried to implement something. It doesn't matter -- the functionality doesn't exist.
When you are designing code, or implementing, or refactoring it, or whatever, then you need to consider how easy it is to modify the result. It's tempting to look ahead and assume that you know what you will need in the future, but you should also consider that you might be wrong (The YAGNI (You Aren't Gunna Need It) rule is surprisingly effective). Generally speaking, as a programmer, your goal should be high throughput of development throughout the lifetime of the project. Attempts to cut corners are usually repaid with interest later down the road. Similarly YAGNI work not only ends up being wasted, it complicates the code base and slows you down later. Because of this, you should usually attempt to just keep the code as simple as possible to modify in general. You should also make the functionality you have developed as accessible as possible (i.e. hiding your functionality under 1000 layers of abstraction is usually a recipe for slowing you down later, even if it looks cool now).
Worrying overmuch about if your code is wrong is probably a class of YAGNI. As long as it is easy and obvious to fix, then it doesn't really matter if it is wrong. Concentrate on making sure that it produces the result that you want and that you can modify it if it turns out to be wrong. Incidentally, it will be wrong. Eventually. You can pretty much count on it.
I like that phrase, that's the key really. Usually the simplest code to modify is the smallest amount of code. But not always, a good abstraction will also make the code easy to modify.
Having good tests, with decent coverage, which are easy to update and add to, probably not overly tied to specific implementation, also helps make code simple to modify.
If you work in a specific domain for long enough, you might be able to predict _how_ requirements will change. Say you are required to handle one widget at time and you're told to implement a process_widget(a_widget) function. But you have been around long enough to know that next week they'll come back and say, "actually, can we process N widgets at a time?" so instead you write a function that is process_widgets(list_of_widgets), and next week you just start accepting a list of more than one item, with the system already tested and implemented to handle the rest. Some might say this is overdoing it, and in most cases it is, but I've seen this happen with more experienced engineers - they'd be a step ahead of the manager and the customer requirements.
If only there was a way to exclude all the bugs that don't fit into this category.
> Incidentally, it will be wrong. Eventually. You can pretty much count on it.
Especially if the theory (and its assumptions) behind how it works is flawed. And there always is such a theory, even though it is rarely ever stated explicitly in any form but the source code. You build that theory with each statement and expression you write, from the considerations and decisions that led you to writing that particular line (BTW, included in those decisions are the choices you made about what to test for (and, implicitly, what not to test), and how each test is implemented.)
That is the point the author is making.
Those antivirus companies patching early windows kernel calls to do their thing sounds pretty horrendous today, but on the other hand, at the time, they made a bad situation better (suing microsoft later was just ridiculous though).
But I think this is point in case why both the SimCity example in this article is flawed. Malloc on DOS behaved that way. Windows 95 kernel calls were patchable. If they couldn't "rely on behavior which is not guaranteed to hold in all future versions", had any of this software seen the light of day?
> Many programs which are correct when viewed at Level 2 are not correct when viewed at Level 3, because they rely on behavior which is not guaranteed to hold in all future versions.
Some level of modularization/abstraction perhaps just keep the system clean, or maybe assist mocking for tests. Maybe even to prepare for some likely changes down the line, but absolutely not "guaranteed to hold in all future versions."
Had Maxis put a lot of extra effort into "proving" that the software wasn't going to break in 8 years that would have likely led to schedule slippage for no particular end-user benefit.
http://spinroot.com/spin/Doc/rax.pdf
This was code that ran on a $150M spacecraft, so getting it right really mattered. It had a formal proof of correctness. We tested the living shit out of it. And it still failed.
So they used code that they had already discovered an error in, in a different subsystem. I would not contend that that means that software verification doesn't work.
If you can't in actuality cause a flaw in a program despite the presence of unsafe operations or faulty reasoning, then I would say that by definition there is no bug. Rather I might say that the code is brittle and perhaps unmaintainable, with risks for people who modify it later. Which is of course bad, but thinking pragmatically there's not much different between a latent pitfall like this and some poorly documented or gnarly spaghetti code that is equivalently difficult to maintain.
Fixing what the author calls "Level 1" and "Level 2" problems is sort of a mandatory imperative, as you can't realistically ship software with prominent issues at these levels. But shipping software with latent "Level 3" problems is something that happens every day, and it might be entirely correct to do the hacky and expedient thing rather than the "correct" thing at this level -- this is not a luxury you can afford at other levels, which makes this level fundamentally not a "bug".
That doesn't mean it's not worth thinking about erroneous reasoning. Erroneous reasoning is often the source of actual bugs that slip through a test suite. And if you're using loose reasoning and relying on assumptions holding true in the future, this is the sort of thing you should be documenting next to the code for the benefit of future maintainers. And indeed we should be working to write more maintainable, safer software, but not under the guise of a "bugfix" -- this is just good software engineering practice.
Thinking about "Level 3" problems is good. But be pragmatic. The reason you're doing it is because they make software difficult to reason about, and hence maintain. Investing time into fixing these problems helps future maintenance, but there are probably a lot of things you can do to improve software maintainability and you do need to actually ship the software at some point.
Undefined behaviour. Can't go wrong now on current compilers with current architectures, but who knows what will happen at the next rounds of optimisations.
https://plato.stanford.edu/entries/knowledge-analysis/#KnowJ...
I wonder if that inspired it, or if the author just ended up heading in that direction>
Many newcomers to C seem to think that just because they wrote something, and it compiled, and it had the expected output, they wrote a correct program. But since there can be UB which results in any (hence undefined) behavior, including whatever the person expected, that really isn't true. Unfortunately that seems to go against many people's expectations/intuition. :/
<ramble>
Conflating a working build for good code, isn't specific to C. Students being taught Java (or any other safe language with an explicit verification/compilation phase) can also be tempted to assume that just because their code finally compiles ok, then it must be the program they hoped to write.
Then there's the issue of the code working for one or two particular combinations of inputs, but not all. Again, failure to test well is not specific to C.
There is the possibility that the UB only rears its ugly head in very rare circumstances, but again, obscure bugs slipping past testing isn't specific to C.
Where C is unique is that, as you say, even where the resulting binary always gives the right outputs (on your current compiler+platform), that doesn't mean you have a 'truly correct' C program, even assuming that platform independence is a non-goal.
To put it another way, exhaustively demonstrating the correctness of the resulting binary, doesn't prove the correctness of the C program. This isn't the case for all other languages, however - sometimes exhaustive testing of the binary can prove program-correctness.
(At least, if we ignore non-deterministic runtime factors like threads' scheduling and RNGs. One could concoct a deterministic subset of Java that would have this property.)
Regarding multi-platform code: It's not just UB that gives C the ability to behave so differently between different compilers/platforms. If you switch platform and are suddenly subjected to a new width of unsigned int, your code might behave differently in this new environment (it might fail to wrap-around when it used to, say) even if it never invokes UB.
</ramble>
> that seems to go against many people's expectations/intuition
In an ideal world we'd take the edge off by requiring all new C programmers to use something akin to Clang's 'ubsan' runtime UB detector.
What it comes down to is that a formal specification is a program we don't know how to compile.
The exceptions to this are specifications that are non-constructive; they define an allowable behavior without defining how the result is computed. An example would be a specification for TCP that specifies that the data must be presented to the receiving program in the same order it was sent; the behavior is simple but the underlying mechanisms are complicated. However this is often not what the specification looks like. More usually its like the specification of a bank transaction, which says that afterwards the sending account must be debited and the receiving account credited. Well gee!
The real problem is that the "correct" behavior of the program is complex and contingent, and making sure you have covered all of the issues during requirements gathering is very difficult. And you can't put a formal specification in front of the customer and say "Is this what you want it to do?" because they can't read it, any more than they can read source code.
An example of an unwritten requirement might be, say, that the word processing program doesn't violate the user's privacy or security. That might not be on the list of functional requirements for a word processor, but would be implicit.
The problem is that requirements can be deficient in some ways. The program is the most visible deliverable related to the requirements and so it takes the blame for requirement problems.
Requirements can be outright wrong and that's when they are 1) contradictory in some way (there exists a subset of the requirements which cannot all be simultaneously implemented) or 2) unclear: they have multiple different interpretations (unintentionally) and such.
When requirements are clear consistent, then they are subject to opinion: someone would like the requirements to be more complete. Or for some of them to be something else entirely. This turns into a criticism of the program: why isn't it that way, rather than this way. People blur the boundary between this kind of criticism and a criticism of correctness. "How can this be a correct word processor; it has only three levels of undo?". (But the requirement specification was written such that it calls for three levels of undo; how can the program not be correct on grounds of that requirement?)
Trying to get people to listen makes you feel like Cassandra. No one listens even though you're right. lol
For example, there's no guarantee a web app will work in a new browser or even a new version of a browser. Browser vendors try to avoid breakage for most apps but not necessarily your app. They will make backward-incompatible changes to rarely used API's.
A web app usually will work with a new browser, but this is an argument based on statistics, not logic. There is good reason to run automated level 1 tests against beta versions of browsers, to get an early warning of breakage.
Reasoning based on API specifications has a lot in common with mock-heavy testing. It can sometimes find logic bugs that are your own fault, but it's no good at finding breakage that is not your fault (but may still be your responsibility).
That was kinda' my point. The article claims that "bug" is not just "coding errors", and I agree - and furthermore, I say that "design considerations are still bugs by that definition"
Well, that and "bug in dependency".
Formal methods are nice and everything but it's a massive waste of time if those are your main problems because you're doubling down on solving the wrong problem.
But the latter can be achieved and some cases the design must be discarded in some ways to achieve it.
I took a grad course about formal verification. Basically, there are ways to mathematically ensure a program will never go wrong.
http://old-www.cs.dartmouth.edu/~cs50/data/tse/wikipedia/wik...
I think a more accurate statement of this sentiment is that investing time and effort into preventing future bugs and problems stops being worth it after a certain point (and perhaps earlier than many software engineers would think).
My favorite excerpt: The Shuttle software consists of ca. 420,000 lines. The total bug count hovers around 1. At one point around 1996, they built 11 versions of the code with a total of 17 bugs.
There are analogs in the mechanical world. Some of the most beloved motorcycles, bikes, cars, boats are easy to work on. Chevy made a straight six that people modified so heavily that people got multiples of the original horsepower out of it. It was simple, sturdy, easy to hang things off of and to modify.
The Open-Closed Principle tries to capture that idea in a sentence. But from all appearances the nuances of the concept seem to entirely escape people far too often.