Defensive & offensive programming
Defensive programming helps with uptime and reliability. Offensive programming helps you find bugs. They're both extremely useful in software.
Defensive programming is a term that many programmers have heard of. It's related to error handling and having correct programs. For some programs, defensive programming is essential. For others, it may be useful to use here and there. Along with that, there's also offensive programming.
In this article, we'll start by examining "normal programming". We'll examine it first because some people mistake it for defensive programming. However, this is something that you should do regardless of whether you do defensive programming or not.
Then, we'll examine defensive programming, followed by offensive programming.
Normal programming
Normal programming means to have all the checks that are necessary in your code. It also means to always handle certain types of errors.
Necessary checks in code
Some code needs a lot of conditionals. It can feel like you're being "overly defensive" with the number of conditionals you have.
One example of this is checking for null
(the billion-dollar mistake). Nulls and null checks are very tricky. Many codebases need if
statements for them all over the place.
Another example is validating user input. You need to have many checks to ensure that user input is valid. Your program needs to handle it very harshly. Otherwise, you'll have security vulnerabilities.
But that's not defensive programming.
Rather, something like forgetting a single null check is a bug. They're not unnecessary checks that you do "just in case". They're necessary checks. The value will be null
sometimes and that's normal. If you forget a single one, you have a bug. No questions asked.
Necessary error handling
Error handling is very important in programs. You always need to consider how your program should respond to errors.
This also depends on the kind of error.
Generally, most programs handle "expected errors" which are out of their control. For example:
- failing to send a network request because the network connection dropped.
- failing to find a file because a user deleted it.
It would be very bad for the user experience for a program to crash on these errors. Also, it's relatively easy to handle them.
As a result, most programs handle these, even if they're not doing defensive programming. So, again, this is considered "normal programming", not defensive programming.
A different kind of error is a bug. In most programs, these errors are considered "unrecoverable". The rule-of-thumb for most programs is to crash on these errors and to not handle them.
Defensive programming
In my interpretation, defensive programming is about fault tolerance. It means going above and beyond to ensure that your program continues working. It's used for certain programs where you need maximum:
- availability
- safety
- security
Example use case of defensive programming
One example of defensive programming, as Adrian Georgescu writes on his post on NASA coding standards, is for code used in space exploration missions.
That code is developed once and sent to space. If it goes wrong, that's billions of dollars worth of work lost.
For that kind of code, you need to take extreme measures. The code must work correctly, without crashing, no matter what.
This is very different to your average program. With your average program, bugs aren't generally a big problem. Your program may still be usable even if it's buggy. In the worst case, a problem can be fixed manually by calling customer service. If the program becomes unusable, you can crash it and restart it. If it's a back end program, there are probably multiple servers running it. If it's a client, the user can restart the program themselves. In a really bad case, you can update the server code. You can even go to a physical server manually and restart it.
But, with certain critical software, you can't do that. The software has to always work properly.
The problem is that people aren't perfect. We create bugs. Not to mention that other errors may occur that are outside of the program's control (such as operating system errors). This means that the program may fail.
But, that's not an option with some software.
As a result, you need to do everything in your power to prevent failure.
How to do defensive programming
Defensive programming primarily means doing everything possible to ensure your program is working correctly and will continue to work correctly. This can include:
- having very good software development practices.
- having many checks in code to double and triple check that everything is working at all times.
- optionally, having error recovery mechanisms. That way, if something goes wrong, maybe the program can recover.
Good software development practices
The first step is to make the code as bug-free and as easy to work with as possible.
That means that you need things such as:
- very strict QA
- very thorough tests
- very thorough runtime monitoring
- very strict coding and development standards. In fact, you may ban certain patterns or language features altogether, such as recursion.
- good general software quality
- source code that's easy to understand
- software that behaves in a predictable manner
Those points are important for all software. However, they're critical for defensive programming. After all, if your source code isn't well tested or easy to understand, it could have bugs. This defeats the point of defensive programming.
Extra checks
Code with defensive programming tends to have many extra checks. The checks are there to catch bugs. They wouldn't be needed if the code was completely bug-free. Checks that aren't intended to catch bugs fall under "normal programming", not "defensive programming".
You have conditionals in the code that check whether something, such as some state in the program, is valid. If a check fails, it shows a bug.
At that point:
- if the program is in development, you can crash it and fix the bug. This is the same principle as using assertions, during development, in offensive programming.
- if the program is in production, you can run error recovery (if you've implemented it) so the program can continue working.
The common case is to crash the program and fix the bug. During development, you hope that the combination of tests and extra checks will catch all of the bugs. Then, when the program is in production, it should work as intended.
Another benefit for these checks is that they catch errors early. The more checks you have that the intermediate state is correct, the sooner you'll catch bugs. That makes debugging easier. It also means that you can start error recovery earlier.
Finally, you may be able to implement some error recovery. Then, if a check fails, you can run your error recovery code.
You can have as many or as few checks as you need. You'll have to decide what to check based on risk analysis. Some important checks are probably results involving important calculations and data. Some less important checks are things like checking function arguments or constantly checking state after simple operations.
Here are some examples of checks you might have:
Example with checking function arguments
You can check whether a function is called with valid arguments. The arguments should have the correct type and range.
Here's a code example:
function foo(nonEmptyString, naturalInteger) {
if (
typeof nonEmptyString !== 'string' || // if it's not a string
nonEmptyString === '' || // if it's the empty string
!Number.isInteger(naturalInteger) || // if it's not an integer
naturalInteger < 1 // if it's not a natural integer (1 or more)
) {
// crash the program
// or handle the error here
// or throw an exception so some code higher up handles the error
// or do anything else your error recovery implementation requires
}
// code for normal function execution
}
Example with checking the results of data calculations
Another example is checking results involving data.
Normally, you would only check some data when you first receive it. For example, if a user submits some data, you would check it to make sure it's valid.
Then, you would work with that data. You might format it or transform it in some way. You would have tests to make sure that these processes work correctly.
In theory, you shouldn't need to also check the final result. The initial data is valid. The code you process it with works correctly. Therefore, the end result should be correct.
But, if you're doing defensive programming, you might have checks on the final result too.
Recovering from unexpected errors
The steps mentioned so far try to reduce the number of bugs in your program. However, there might still be bugs. For that reason, you might want to implement error recovery.
This might require a lot of thinking. It might even need to be part of your feature planning. This would be the case if the program needs to respond to a user while it's in the process of recovery. The user-facing behaviour will probably be determined in collaboration with a product manager, not just by the programmers.
Also, error recovery might be a large part of the code. As a made-up example, consider a back end that accepts network requests for product orders. A server might error while processing the order. To handle that scenario, you might do things like:
- have an initial server record the order information so it's not lost.
- have some recovery mechanism for the faulty server. E.g. some other process may restart it. Or, maybe the server can try to fix its own state internally.
- the order can be given to a different server, or maybe the erroring server can try to process it again after it's fixed.
Here are some more examples of possible recovery mechanisms. If something in the code fails:
- maybe you can try to manually fix or reset the state in the program.
- maybe you can try running the operation again. If the problem is a race condition, it may work next time.
- if it's a subprogram that's erroring, maybe you can restart it. If the problem is invalid state in the subprogram, then restarting it may work.
- maybe you can have a backup program hosted on a server. If the client is producing incorrect results, then maybe it can call on the server to do the calculation instead.
- maybe you can have a backup program with less features than the main program. If the main program is erroring, maybe run the backup program instead which only provides barebones operation.
Of course, if a critical part of the program is buggy then maybe you can't do anything about it in runtime. The only solution may be to fix the code.
You'll also need to have risk analysis. That's where you consider things like:
- what code could have errors?
- how likely it is that it will have errors?
- what impact would the error have?
- what would it cost to prevent the error from ever happening or to implement recovery mechanisms for that error?
The idea is that recovery will need to be considered as a first class citizen and a requirement during the project.
Note that these kinds of recovery measures are probably reserved for programs that really need defensive programming. For most normal programs, it's probably enough to simply restart a server or notify the user that something went wrong.
Downsides of defensive programming
Defensive programming has significant downsides. For example:
- it requires a lot more code. At the very least, you'll have many more conditions and checks than a similar program without defensive programming.
- performance can be worse. That's because the extra checks take time to execute.
- it makes the code harder to work with because there is a lot more code.
- error recovery can take a long time to plan for and implement.
When to use defensive programming
Whether or not you use defensive programming depends on your program.
As mentioned earlier, some programs need maximum availability, reliability and security. Those types of programs may require a lot of defensive programming.
For most other programs, you shouldn't need defensive programming. "Normal programming" should be enough. Nonetheless, you're free to use some defensive programming techniques around some key areas of the code. It's up to you to make the decision.
Regardless of what you do, remember to be pragmatic. Use risk analysis. Consider:
- what could go wrong?
- how much chance is there of it going wrong?
- what would the impact be?
- how could you prevent it from going wrong?
- what would it cost to implement prevention or recovery?
Then, use the right amount of defensive programming where necessary. Try to avoid overusing defensive programming if it's not necessary.
Offensive programming
The goal of offensive programming is to catch bugs and crash early. As explained in how to respond to errors, crashing early is helpful.
It means that you are notified of bugs immediately. Also, the stack trace from the crash is closer to the source of the problem. This helps with debugging.
How to do offensive programming
To do offensive programming, you:
- do normal programming
- don't recover from bugs (avoid defensive programming)
- write code in a way where bugs are obvious and easy to find
- immediately crash the program on bugs
Just like with normal programming, you still need conditionals for things that aren't bugs. For example, you need conditionals for null
checks.
Similarly, you should probably handle errors which aren't bugs. For example when users provide invalid data, or when you're not able to find a file in the file system. Most of the time, it would be unreasonable to crash on them. In other words, you should probably follow the "normal programming" way of dealing with these.
Also, you should write code in a way where bugs are easy to find. Here are some techniques for that.
Avoid fallback code and default values
Things like default state, default arguments and fallback code can hide bugs.
For example, you might call a function with incorrect arguments. You might have accidentally used null
instead of a string for an argument. That's a bug. However, due to default arguments, the argument will be a string anyway. The bug won't be caught and the program may do the wrong thing as a result.
A similar thing applies to fallback code. One example is inheritance and subclassing. You may have forgotten to implement a method in a subclass. Then, you call the method and it executes the parent's method. That's unintended behaviour, which is a bug.
To prevent this, avoid using things like default state, default values and fallback implementations.
Avoid checks on code that will crash on errors
Sometimes, buggy code will crash on its own. You don't have to do anything extra. Leave the code as it is and let it crash.
For example, consider the code below. array
should never be null
. If it's null
, that's a bug.
If you have a defensive check around it, the code won't crash:
function foo(array) {
if (array !== null) { // code doesn't crash if array is null
return array[0];
}
}
But if you don't have a defensive check, the code will crash.
function foo(array) {
return array[0]; // code crashes if array is null
}
You want the code to crash as early as possible. So, in this case, just leave it as it is without a defensive check.
Have conditionals or assertions to check for errors
Contrary to the point above, some bugs won't cause the program to crash.
For example, you might have some incorrect state in your program. Your program may not crash from that.
As another example, some code may execute that shouldn't execute under normal circumstances.
In these cases, you can use manual checks. Then, if you find something wrong, you can manually crash the program.
For example:
function foo(arg) {
switch(arg) {
case 'foo':
// do something
break;
case 'bar':
// do something
break;
default:
// this code should never execute, so crash the program if it does
throw new Error('Default case should never execute.');
}
}
Here's another example with checking state:
function getCurrentPlayerHealth() {
const health = player.health;
if (health < 0 || health > 100) {
// this condition should never evaluate to true, so crash the program if it does
throw new Error(`Player health should be between 0 and 100.`);
}
// continue normal function execution
}
More traditionally, these kinds of "bug checks" use assertions instead of conditionals.
Assertions are bug-finding tools. If they fail, they signify a bug. Conditionals are control-flow tools. If a conditional "fails", it doesn't signify a bug. It means that a different block of code should execute instead.
So, instead of using conditionals, you can use assertions. For details on how to do that, please see the documentation for your programming language.
Here's a code example in JavaScript:
console.assert(player.health >= 0 && player.health <= 100, player); // logs a stack trace if condition is false, along with the player object
In some programming languages, assertions crash the program. However, in others, they don't crash it. They may only print an error message to the console or something. Both are usable. However, offensive programming recommends hard crashing when possible.
Also, some programming languages allow you to turn off assertions in production for better performance.
Downsides of offensive programming
Similar to defensive programming, offensive programming has downsides.
One downside is having to avoid certain kinds of code like default arguments. Default arguments have valid use cases. They provide "reasonable defaults". They can make some code much easier to work with.
Another downside is having to crash the program. As explained in how to respond to errors, crashing on bugs is usually good. However, it might be something that you're not prepared to do in your application.
Another downside is performance. Having assert statements throughout your code can significantly reduce performance.
As a result, many programming languages don't crash when assertions fail. Also, they have the option of removing assertions from production code. With this option, you lose the benefits of offensive programming in production. You only gain the benefits during development. However, that alone can be very useful.
When to use offensive programming
Offensive programming helps you catch bugs. That's a significant win.
For this reason, it's good to use it during development. Generally, you'll put assert statements here and there to ensure that certain things are correct.
As for production, it depends. Consider the pros and cons of offensive programming and make your decision.
It's alright to only use offensive programming in development. After all, catching more bugs during development is better than nothing.
Be pragmatic
When choosing your approach to handling errors, you need to be pragmatic.
"Normal programming" is the minimum that you need to do for most programs.
For some programs, you might use defensive programming. In particular, for programs that need high:
- availability
- security
- reliability
But also understand the downsides. Primarily, the downsides are worse performance and longer development time.
Offensive programming helps you catch bugs. This is useful during development (and even production).
You can mix and match the approaches based on what you need. You can even use different methodologies in different areas of the code. It's up to you to decide.
Final notes
So that's it for this article. I hope that you found it useful.
As always, if any points were missed, or if you disagree with anything, or have any comments or feedback then please leave a comment below.
For the next steps, I recommend looking at the other articles in the error handling series.
Alright, thanks and see you next time.
Credits
Image credits:
- Turtle in sea - Photo by Tanguy Sauvin from Pexels
- Turtle in shell - Photo by Hogr Othman on Unsplash
- Tiger - Photo by Samuele Giglio on Unsplash
- Squirrel - Photo by Pixabay from Pexels