Errors should never pass silently. Unless explicitly silenced - Zen Of Python
Return victorious from war, or do not return at all - Samurai Principle
The Webster dictionary defines exception as
a case to which a rule does not apply and an error is
an act or condition of ignorant or imprudent deviation from a code of behavior. These two definitions have been given to show how the terms are similar. The difference between the two terms in real life, is the severity of the problem. Exceptions are considered less fatal, but they all amount to one thing, an rule or an expectation has been violated. For this article , exceptions will be used to indicate any deviation of some sort. Readers should however bear in mind that, not all exceptions are errors. Some exceptions are used to control the flow of execution of a program. A prime example is python's StopIteration exception.
Exceptions are provided to help get information from where a violation is detected to where it can be handled. A routine, method or a function that cannot cope with a problem throws an exception, hoping that its direct or indirect caller can handle the problem. A caller that can handle a problem, indicates its ability to do so by catching the exception. Anytime we write a piece of code, we make a promise that the code will work given specific conditions, in a specific way. Exceptions are a way for the code to say, "Okay , these conditions under which I can work successfully were not provided, so I can not finish my work". These conditions can be : the database server should be running, the host device must have a working internet connection, an integer provided should be in a specific range or a key must exist in a dictionary. Consider the following piece of code.
#A function to divide two integers. #The dividend must be in range 2, 127. #The divisor must not be greater than half the dividend. #The return value of the function will always be a positive integer greater than zero . def special_divide ( dividend, divisor ): if dividend not in range( 2, 128 ) : raise ValueError("Dividend must be in range 2 - 127") if divisor * 2 > dividend: raise ValueError("Divisor must not be greater than half of the dividend" ) return int(dividend) // int(divisor)
I choose python because of readability, conciseness and practicality. The code here is promising to return an integer greater than 1 but less than 128. In order to not break that promise, its saying "I will only work under these conditions: The dividend must be in a specific range, the divisor must not be greater than half the dividend. In a case where these conditions are violated, I will fail to perform my task, therefore I will throw an exception to indicate my failure to do so." These two conditions are the pre-conditions of the function. It is not enough to state these preconditions in comments. The code must be designed to enforce these preconditions. This illustrates the practice of not allowing errors to pass silently and returning only when successful. Raising an exception draws attention to the situation and provides enough information for the code that called this function to understand what happened. One thing to notice is that, ValueError is not the only exception that can be raised. If the user provides a 0 value to the divisor, a ZeroDivisionError is raised. If the user provides non integer value, a TypeError is raised by the function int(). If a floating point value is provided, the function says, "Okay I can handle this error by converting the floating point to an integer, so I will do just that. ( This should be documented by the programmer to make the caller aware)". In the case where all these conditions are satisfied, the function returns its promised value. Before exception handling approach, let us consider the alternatives available to a function detecting a problem that cannot be handled locally, so that an error must be reported to a caller.
The first approach was to return an error code. In this approach, a function that fails to perform its task returns an error code as an indication of error. Popular values includes negative values, zero, null values, false. This approach was not always feasible. Consider the function.
def read_next_integer_from_input() : read_integer = 0 # read next integer from input stream return read_integer
For a function like this, every integer is a possible return value. There can be no integer value representing an input failure. The least we can do is to make the function return a pair of values, one indicating the return value and the other indicating whether an error occurred or not. Even where this approach is feasible, the programmer must check the result of every code. This quickly doubles the size of the code, and makes room for even more errors. Programmers, however tend to forget to check the return value of every single function they call. This causes errors to pass easily and silently. What about functions that by language definition, are not allowed to return a value ? An example is a constructor. This approach also forces the programmer to make every function return a value, even for functions that by design should not return, or void functions, in technical terms.
Another approach was to leave the program in an error state. In most cases, this is done by setting a global variable to a certain value.
if error_occurred: global_error_value = 1 else : global_error_value = 0
The programmer, after every function call, then checks if this variable show an indication of error. An prime example is C errno variable. Most C standard library functions set this variable to a number other than 0 to indicate an error. This quickly suffers from the deficiencies of the first approach. The programmer may fail to notice that the program has been put in an error state. Programmers may also forget to set the global variable in their functions to report errors. The use of global variables may also cause a lot of problems in the presence of concurrency, without extra work.
Another approach was to terminate the program. This was a very drastic step. For most programs, we can and must do better than that.
if error_occurred: exit()
At least we can produce a decent message for the user before exiting the program. In particular, a library that does not know about the purpose and general strategy of the program in which it is being used cannot just terminate the program. That would be very bad for the program. Such a library that unconditionally terminates cannot be used in a program that cannot afford to crash.
The last approach was to call an error handler. This must be some other approach in disguise because the problem immediately becomes ‘‘What does the error-handling function do?’’
if error_occurred: error_handler()
Unless the error-handling function can completely resolve the problem, the error-handling function must in turn either terminate the
program, return with some indication that an error had occurred or set an error state. But wait a minute, if the error-handling function can handle the problem without bothering the ultimate caller, why do we consider it an error ?
Traditionally, a combination of these approach were used to report and handle errors. Sadly, modern programmers who do not understand the use of exceptions, use these approaches in their code. The result is seen in subtle bugs, errors that cannot be detected during development until later, and many undiscovered errors that are passing silently. Debugging the program also becomes stressful. These approaches can therefore not be used to detect all errors. This has caused the need for exceptions support in languages.
In my next article, I will discuss the history of exceptions, the technical details of exception mechanism, that is, how languages implement it, practising offensive programming , and how to avoid "try-catch code hell".