Error handling is critical in any software application. In computational applications, good error handling code will to detect the error as early as possible (fail fast), exit immediately, and report useful diagnostics to the user.
Boolean Return Value
One technique is to have every method return a boolean value. If the method returns true, proceed as usual, otherwise exit immediately.
function ProcessRegions() result(success) logical :: success success = .false. do i = 1, N region = regions(i) if (.not. ComputeDensity(region, density)) then return end if ! Do more stuff end do ! No errors. Return true. success = .true. end function
The downside of this technique is that you have no context as to what or why it failed. Why did the density computation fail? Was the mass zero? Did ComputeDensity method attempt to open a file that does not exist? There are 10,000 regions. Which region failed?
Without this information, you will need to debug the code interactively to figure out what went wrong.
Error Code Return Value
This is the classic programming technique of returning an integer error code. If the code is zero, by convention the method was successful. Otherwise exit immediately. The developer is responsible for making an enumeration containing error codes for all methods of failure.
integer, parameter :: ERR_None = 0, & ERR_Default = 1, & ERR_DivideByZero = 2, & ERR_FileNotFound = 3, & ERR_SomethingTerrible = 4 subroutine ProcessRegions(err) integer, intent(inout) :: err ! inout since the err code should already be initialized to ERR_None. do i = 1, N region = regions(i) call ComputeDensity(region, density, err) if (err /= ERR_None) then ! Exit immediately if there's an error. return end if ! Do more stuff end do end subroutine
The error code now adds some context. We can now distinguish between a divide by zero error and a file not found error.
1) We still don’t know which region failed. This is very important if we are processing many regions.
2) We need to add an error code for every failure point in the application. For example a divide by zero error could occur in the ComputeVelocity() method. Therefore we need separate divide by zero error codes. This quickly becomes cumbersome – which leads to error handling code not being written.
3) Each method call is accompanied by three extra lines of error handling boilerplate. This code will be duplicated throughout the application.
Derived Type + Macros
It’s more convenient to use a piece of text instead of a raw integer. Let’s make a derived type.
type :: ErrorType integer :: Code character(len=256) :: Message end type
OK so now we have a richer data type to store an error message. The downside is that it is more verbose to define an error and handle an error. Next let’s add some macros to handle this.
To use macros:
1) Enable the Fortran preprocessor compiler option (/fpp). In Visual Studio use Fortran | Preprocessor | Preprocess Source File.
2) Include the file with the macros using an include statement i.e.
#include 'Error.fpp'. Beware of any leading spaces in this statement.
! Error.fpp ! Macros for error handling. ! Enables user to store errors and exit the subroutine in single statement. ! Fortran preprocessor must be enabled: -fpp. ! Raise Error ! Store the error code and info (only if the current code is zero). ! Return from the subroutine. #define RAISE_ERROR(msg, err) if (err%Code == ERR_None) then; err = ErrorType(Code=ERR_Default, Message=msg); end if; return; ! Pass Error ! Returns if there's an error. #define HANDLE_ERROR(err) if (err%Code /= ERR_None) then; return; end if;
These macros eliminate some of the boiler plate error handling code. The
RAISE_ERROR macros throws and stores the error and the
HANDLE_ERROR checks the error code and exits the subroutine as necessary.
Now the full example looks like:
#include 'Error.fpp' module Processing use Errors ! Error type definition and constants. implicit none contains subroutine ProcessRegions(regions, err) integer, intent(inout) :: err do i = 1, N region = regions(i) call ComputeDensity(region, density, err) HANDLE_ERROR(err) ! Do more stuff end do end subroutine subroutine ComputeDensity(region, density, err) type(RegionType), intent(in) :: region real(8), intent(out) :: density type(ErrorType), intent(inout) :: err if (region%Volume <= 0.0) RAISE_ERROR("Volume must be greater than zero. Region = " // trim(region%Name), err) end if density = region%Mass / region%Volume end subroutine end module
The error message is now very descriptive. Raising an error and handling an error only takes a single line of code.
Global vs. Local Error State
The above examples store the error state using a local variable. It is tempting to use a global variable for the error state so you don’t have to pass it around as a parameter. However, using a local variable is the best practice.
1) Any subroutine that can fail will have an error variable in the interface. This helps ensure that the caller will remember to handle the error.
2) Using local variables ensures that the application can be safely executed in multi-threaded context. This is an important consideration if you ever want to run your application in parallel.