Error-free software? A pipe dream! Automated tests are standard and yet in practice there is still a lot of catching up to do when it comes to testing. Is "defensive programming" therefore an additional building block that needs to be followed? Yes and no. When we question in our training sessions whether the parameter check at the start of each method really makes sense, there are usually lively discussions about error handling. Because something can go wrong anywhere, it must make sense to be careful everywhere. No! We need to take a differentiated approach to error handling.
Differentiation of error handling
In order to achieve more clarity, we should first try to divide errors into different categories. This will break the problem down into smaller problems and we can think about what form of error handling makes sense in each category. We identify the following categories:
- Incorrect operation by the user (e.g. incorrect or missing entries)
- Problems in the environment (network problems, timeouts, file not readable, etc.)
- Developer error
The distinction between developer errors and the other two categories is very important. Errors that we make as developers manifest themselves, for example, in null pointer exceptions, index out of bounds, etc. For languages that are executed on a managed platform, e.g. C#, Java, Python, the runtime catches developer errors and reports them in the form of an exception. As a developer, I no longer have to check at runtime before each instruction whether I can access the variable used at all. The attempt to access an invalid memory address is prevented by the runtime and execution no longer leads to a catastrophe (remember MS-DOS?). It therefore makes no sense at all to constantly check whether a reference may point to nothing (null) before accessing it. The same applies to indexes. With indexed access to an array or a list, invalid memory areas cannot be accessed purely technically, as the runtime reacts with an IndexOutOfBounds exception beforehand. Please note that I am not talking about unmanaged code or embedded environments here, where the situation may be different.
So if modern runtime environments prevent something "bad" from happening when accessing invalid memory addresses, a reflexive parameter check in the sense of defensive programming is no longer necessary today. The application is aborted. Incidentally, I also see almost exclusively zero checks. The validity of an array index tends not to be checked. This is also an indication that errors tend to be handled unconsciously and without reflection. Let's now take a closer look at the three categories of errors.
Operating error
When users use software, operating errors occur. Incorrect or missing entries are examples. As developers, we have to assume that operating errors will happen and that they will not occur unexpectedly. Developer errors occur unexpectedly, operating errors must be taken into account in the design. As a rule, it is a requirement that an application gives the user the opportunity to correct incorrect entries. Consequently, when planning a solution, we must always include the possibility of operating errors. This is particularly necessary in order to find a suitable place in the code where the error message is displayed to the user. This can quickly lead to a violation of the Single Responsibility Principle (SRP).
If, for example, a function is to extract a value from the command line parameters, we must assume that operating errors will occur. It could be that there are not enough values in the array or that these strings are in the wrong format. In this case, we cannot simply return zero or some other magical value and hope that it will somehow work. No, such a function has two possible outcomes: either it returns the expected value from the command line parameters or it signals that this is not possible. As the user of the function, I would like to see in the signature that I have to consider two cases. This is not the case with the following function:
string GetFilename(string[] args) { ... }
The function returns a string as the return value. As the caller of the function, how should I recognize that I have to consider two cases? Which specific value is returned instead of the file name? And please forget zero again very quickly. In all languages, great efforts are being made to reduce the use of null. Tony Hoare introduced null references in ALGOL in 1965 and calls this his "billion-dollar mistake".
Note: do not pass zero to a function, do not return zero. Instead of catching null errors, it is better to fix the root problem.
Handling of errors
Better variants for functions that only sometimes return a value can be seen in the following code snippets:
Option GetFilename(string[] args) { ... }
Here the caller optionally receives a string, expressed by the generic type Option. In many languages a Option type has now become the standard. There are numerous NuGet packages for such a type for .NET. Further details in the article Optional and the IOSP.
(bool, string) GetFilename(string[] args) { ... }
The second example shows how information about whether a file name could be extracted can be expressed using a tuple. Here, the caller receives a Boolean value in addition to the file name, which can be specified with false indicates that no file name could be extracted.
bool TryGetFilename(string[] args, out string filename) { ... }
The third case corresponds to the pattern that Microsoft uses when parsing strings into various types (e.g. int.TryParse). The function TryGetFilename supplies true if a file name could be taken from the parameter array. The file name is then displayed in the out Parameters filename is stored. If no file name can be extracted, the function false and the parameter filename is not further defined in terms of content. Due to the C# syntax, the value has of course been initialized by the function with a value. However, this has no meaning for the caller because the return value false was signaled that the file name could not be extracted. The same applies here: forget zero! By the way, the TryGet... pattern sets Call By Reference and is therefore not possible in Java, for example.
void GetFilename(string[] args, Action onFilename, Action onNoFilename) { ... }
The fourth variant works with two callbacks. If a file name can be taken from the array, the first callback is called with the file name as a parameter. If no file name can be extracted, the second callback is called without parameters. With this variant, too, the caller can clearly see that it must take two cases into account.
Which variant a developer chooses must be discussed in the team on a case-by-case basis. Each one has its advantages and disadvantages.
One more comment on error messages: these should never be defined randomly throughout the application. Error messages are part of the user interface and therefore belong exactly there. If an error has occurred, an error code may have to be delivered to the Ui and displayed there as an error message. The translation of an error code into an error message also paves the way for the local language translation of an application.
Problems in the surrounding area
Another category of expected errors are problems in the environment of the software system. When accessing a file, there will come a time when the file cannot be read. This may be because it does not exist or the user's authorizations are not sufficient. The same applies to other resources. The database is not accessible at some point, the web service does not respond, etc. Because this is the case, this category of errors must also be planned in advance. It is not enough to muddle through the code somehow, but a design must show which cases can occur and how they are dealt with.
Here, too, simple signatures are not enough. Let's take the simple case of file access:
string ReadFile(string filename) { ... }
Again, it is not clear what happens if the file cannot be read. Technically, an exception occurs. This should be recognized within the ReadFile method in order to then use a suitable signature to express that the read was successful or not. Examples of signatures can be found above. The four variants shown above are also suitable for this category of errors, as it is again a matter of expressing that a value is only optionally supplied.
In some cases, it makes sense to signal a problem in the environment with a separate exception type. If a deep call stack has to be dismantled because various methods have been called in a deeply nested manner, the easiest way to get back to the top is by throwing an exception. Exceptions and the try / catch mechanism have been included in programming languages precisely for such cases. The exception object can also transport additional information. It is therefore necessary to differentiate again here. In a deep call hierarchy, the use of try-catch at the top level makes sense. With a flat structure, you can also work with return values, as these then only need to be checked in a few places. It also depends a little on the programming language used. In C#, it is rather unusual to use exceptions as a normal control flow. In Python, on the other hand, it is more common.
Developer error - Error
To deal with developer errors, I recommend running them via an exception in a global exception handler. Exceptions can occur implicitly and explicitly. Implicitly by the runtime recognizing a problem and acknowledging it with an exception, e.g. with a null access. The occurrence of a runtime error is implicitly signaled by an exception. We explicitly signal errors by recognizing a programming error as a developer and explicitly communicating this by throwing an exception. This can be, for example, a parameter check on a public method. An error handling routine usually makes no sense here. The program flow is interrupted because we as programmers have made an error.
As there is usually nothing that can be done in the event of a developer error other than to terminate the program or restart it automatically, it is usually sufficient to catch all exceptions in the global handler. It is of little help here to repeatedly wrap code areas in a try block.
Differentiation may make sense at module boundaries. However, it is not very helpful to wrap calls in try/catch statements in all possible places if only a log message can be written in the end.
Conclusion
When it comes to error handling, the focus is on consciously dealing with the three categories of errors (operating errors, problems in the environment, developer errors). This differentiation alone leads to better decisions in terms of the values of changeability and correctness. It requires communication within the team to agree on a common approach. After the differentiation into the three categories, it is also a matter of adhering to conventions that the team sets itself. The most important recommendation is: develop a solution for the two categories of operating errors and problems in the environment. These two issues cannot simply be solved at the code level, but need to be thought through beforehand. And, of course, automated tests help to reduce the time spent in the debugger to find out why an exception was thrown.
Clean code training
Dates of the individual training days:
Closed company courses
We conduct all seminars as closed company courses for you.
If you are interested or have any questions please contact us.