Fortran Error Handling Techniques

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.

The downsides:
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.

Leave a Reply