Skip to content
/ errorfx Public

Robust exception like error handling using modern Fortran

License

Notifications You must be signed in to change notification settings

aradi/errorfx

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

19 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

ErrorFx

Fortran library providing exception like error handling using modern Fortran. The error handling mechanism it implements is

  • robust: an uncaught error stops the code automatically,
  • convenient: propagation of errors upwards through the call stack is easily possible,
  • pure: it only requires Fortran 2008 constructs.

It is written in Fortran 2008. If your project uses an appropriate preprocessor (such as the Fypp preprocessor), preprocessor macros can be used to make the error handling code even more compact.

Several examples demonstrating various error handling scenarios can be found in the examples/ folder. Files ending with *.f90 contain the pure Fortran examples, why files ending with *_fypp.fpp their simplified equivalent using Fypp-constructs.

The project is released under the BSD 2-clause license.

Building

Use the usual CMake workflow to build the library and the examples:

mkdir build
cd build
cmake ..
make

The executables built from the examples can be found in the ./examples folder in the build directory.

By default, the Fypp-based examples are included when building the project. In case you want to build without Fypp, pass the -DWITH_FYPP=NO option to CMake.

General concept

The general recipe for the exception like error handling in Fortran is can be formulated as follows:

  • Define a derived type which allows to store the relevant information about your critical errors. Add an activation flag with .false. (deactivated) as default value. Add a finalizer, which calls error stop if it is called for an activated instance.
  • Create an allocatable (but non-allocated) instance of the error type and pass it to the routine which may throw a fatal error. The corresponding dummy argument should have the allocatable, intent(out) attributes. The allocation status of the variable on return will signalize the error status (non-allocated: no error, allocated: error).
  • If an error occurs (within the called routine), allocate the error variable, fill it up with the necessary information, set its activation flag to .true. and return.
  • In the calling routine, check the allocation status of the error variable immediately after the call. If it is allocated (error occured), either propagate it further upwards or handle it locally. If you handle it locally, deactivate the error (by setting its activation flag to .false.), execute the handling code and then finally deallocate the error.

This mechanism provides a very flexible and robust error handling. Errors can be easily propagated upwards to the top level (as it should be the case for robust libraries). Additionally, it ensures, that errors are not overlooked by mistake, as an activated error stops the code when it goes out of scope.

Implementation details

Error type

The error type in ErrorFx is type(fatal_error) [src/errorfx.f90]. When a subroutine is called, which may throw a critical error, the code in the caller would look as

type(fatal_error), allocatable :: error
:
call routine_with_possible_error(..., error)

while the routine possible raising the error would look as

subroutine routine_with_possible_error(..., error)
  :
  type(fatal_error), allocatable, intent(out) :: error
  :

end subroutine routine_with_possible_error

Throwing an error

Throwing an error consists of allocation, filling information, activation and returning as explained above. ErrorFx offers convenience functions to make this as simple as possible, so that you only have to write [examples/catch.f90]

subroutine routine_with_possible_error(..., error)
  :
  type(fatal_error), allocatable, intent(out) :: error
  :
  ! Creating and throwing an error (activation included)
  call create_error(error, message="Error created in routine_with_possible_error")
  return

end subroutine routine_with_possible_error

If you happen to use Fypp, you can further simplify the code, by writing [examples/catch_fypp.fpp]:

subroutine routine_with_possible_error(..., error)
  :
  type(fatal_error), allocatable, intent(out) :: error
  :
  ! Creating and throwing an error (activation included)
  @:throw_error(error, message="Error created in routine_with_possible_error")

end subroutine routine_with_possible_error

Propagating an error upwards

If a called routine signalizes an error, you can either handle it in the caller or propagate it further upwards. The propagation happens by simply returning if the error is allocated. Of course, the routine propagating the error upwards must itself have a corresponding error dummy argument [examples/propagate_error.f90]:

subroutine routine_propagating_error(..., error)
  :
  type(fatal_error), allocatable, intent(out) :: error
  :
  call routine_with_possible_error(..., error)
  ! If error happend, we propagate it upwards, otherwise we continue
  if (allocated(error)) return
  print "(a)", "Apparently no error occured"
  :
end subroutine routine_propagating_error

Again, you can use some Fypp magic to be more descriptive [examples/propagate_error_fypp.fpp]:

subroutine routine_propagating_error(..., error)
  :
  type(fatal_error), allocatable, intent(out) :: error
  :
  call routine_with_possible_error(..., error)
  ! If error happend, we propagate it upwards, otherwise we continue
  @:propagate_error(error)
  print "(a)", "Apparently no error occured"
  :
end subroutine routine_propagating_error

Catching an error

If you do not want to propagate the error upwards, you have to handle it locally, deactivate it (and eventually also deallocate it). The corresponding catching pattern in ErrorFx would look as [examples/catch.f90]

call routine_with_possible_error(..., error)
if (allocated(error)) then
  call error%deactivate()
  ! Do whatever is needed to resolve the error
  print "(a,a,a,i0,a)", "Fatal error found: '", error%message, "' (code: ", error%code, ")"
  deallocate(error)
end if

Doing the deactivation (call error%deactivate()) as the very first step warranties, that the pattern works also in those cases, where you leave the scope (e.g. via return) during the error handling. If the error handling code does not leave the scope, you can do the deactivation and deallocation together at the end of the error handling block using the convenience routine destroy_error():

call routine_with_possible_error(..., error)
if (allocated(error)) then
  ! Do whatever is needed to resolve the error
  ! Make sure you do not leave the scope, as the error is still active!
  print "(a,a,a,i0,a)", "Fatal error found: '", error%message, "' (code: ", error%code, ")"
  ! Deactivate and destroy in one step
  call destroy_error(error)
end if

As the "manual" error handling is somewhat error prone (you may forget to deactivate or deallocate), ErrorFx offers you the possibility to handle the error via a dedicated (internal or external) subroutine. The library will first deactivate the error, then call the error handling routine and finally deallocate the error [examples/catch.f90]:

subroutine main()

  type(fatal_error), allocatable :: error

  call routine_with_possible_error(..., error)
  call catch_error(error, error_handler)
  :

contains

  subroutine error_handler(error)
    type(fatal_error), intent(in) :: error

    ! Do whatever is needed to resolve the error
    ! (Deactivation/deallocation is done by the library automatically.)
    print "(a,a,a,i0,a)", "Fatal error found: '", error%message, "' (code: ", error%code, ")"

  end subroutine error_handler

end subroutine main

The error handler routine can be an arbitrary subroutine, which takes the thrown error type as intent(in) argument. If it is an internal subroutine, it will even have access to all variables of the hosting scope (e.g. error_handler() can access all variables defined in main() above).

Finally, with Fypp you can write a compact, robust and descriptive error catching construct even without explicit error handling routines as [examples/catch_fypp.fpp]:

call routine_with_possible_error(..., error)
#:block catch_error("error")
  ! Do whatever is needed to resolve the error
  print "(a,a,a,i0,a)", "Fatal error found: '", error%message, "' (code: ", error%code, ")"
#:endblock

Rethrowing an error

If during error handling of a caught error it turns out, that the error can not be handled locally, the code may either throw (create and propagate) a new error or just rethrow the original one. Latter can be achieved by activating the error again (in case it was deactivated already) and returning:

subroutine routine_rethrowing_error(error)
  type(fatal_error), allocatable, intent(out) :: error

  call routine_throwing_error(error)
  if (allocated(error)) then
    call error%deactivate()
    :
    ! Rethrowing error
    call error%activate()
    return
  end if
  :

Note, that if you do not leave the scope via return during the error handling (except when rethrowing the error), the calls error%deactivate() and error%activate() can be omitted.

The compact Fypp based analog would be

subroutine routine_rethrowing_error(error)
  type(fatal_error), allocatable, intent(out) :: error

  call routine_throwing_error(error)
  #:block catch_error("error")
    :
    ! Rethrowing error
    @:rethrow_error(error)
  #:endblock
  :

Failure due to an uncaught error

If an error is not caught (deactivated), it will trigger an error stop when it goes out of scope. You will get an appropriate error message and given on your compilation flags, you may also obtain some traceback information starting from the location where the error went out of scope [examples/fail_uncaught.f90]:

subroutine routine_failing_due_unhandled_error()

  type(fatal_error), allocatable :: error

  call routine_with_possible_error(..., error)
  ! Error was neither caught nor propagated. It would trigger an error stop at
  ! the end of the subroutine

end subroutine routine_failing_due_unhandled_error

Running the above example, you would obtain an error stop with some information:

Stopping due to unhandled critical error
Error message: Error created in routine_with_possible_error
Error code: 0
ERROR STOP

Error termination. Backtrace:
#0  0x7f5a2fb30d21 in ???
#1  0x7f5a2fb31869 in ???
#2  0x7f5a2fb32f97 in ???
#3  0x55e8d176876a in __errorfx_MOD_fatal_error_final
        at errorfx/src/errorfx.f90:125
#4  0x55e8d1767abb in __errorfx_MOD___final_errorfx_Fatal_error
        at errorfx/src/errorfx.f90:196
#5  0x55e8d176638a in main
        at errorfx/examples/fail_uncaught.f90:18

If you use Fypp for the same example [examples/fail_uncaught_fypp.fpp], the error message will be more informative, as it will also contain the propagation path of the error itself, so you will know, where it was triggered and how it was propagated up without going out of scope. Latter can be very useful, if the error was propagated upwards through several levels:

Stopping due to unhandled critical error
Error message: An error occured in routine1()
Error code: 0
Error propagation path:
errorfx/examples/fail_uncaught_fypp.fpp:26
ERROR STOP

Error termination. Backtrace:
#0  0x7fd723fe5d21 in ???
#1  0x7fd723fe6869 in ???
#2  0x7fd723fe7f97 in ???
#3  0x559f121b279f in __errorfx_MOD_fatal_error_final
        at errorfx/src/errorfx.f90:125
#4  0x559f121b1af0 in __errorfx_MOD___final_errorfx_Fatal_error
        at errorfx/src/errorfx.f90:196
#5  0x559f121b03bf in main
        at errorfx/examples/fail_uncaught_fypp.fpp:20

Extending errors

Sometimes, it may be desirable to extend the fatal_error type. Either, because you wish to create some errors which carry more information than the base type does, or because you wish to differentiate between errors based on their class (by creating an error class hierarchy as you find for example in Python).

The extension is straightforward. The following example demonstrates, how an I/O error could be introduced, which also contains the filename and the unit associated with the I/O problems. Appart of the type extension, one should also provide convenience function to catch an error of the extended type and of the extended class [examples/error_extension.f90]:

module error_extension
  use errorfx, only : fatal_error, fatal_error_init
  implicit none

  private
  public :: io_error, io_error_init, catch_io_error_class
  public :: create_error, catch_error, destroy_error


  !> Specific I/O error created by extending the general type
  type, extends(fatal_error) :: io_error
    integer :: unit = -1
    character(:), allocatable :: filename
  end type io_error

  !> Error creator (use those routines to create an error in the code)
  interface create_error
    module procedure create_io_error
  end interface create_error

  !> Catches specific error types
  interface catch_error
    module procedure catch_io_error
  end interface catch_error

  !> Deactivates and deallocates a specific error type
  interface destroy_error
    module procedure destroy_io_error
  end interface destroy_error

contains

  !> Creates an IO error.
  pure subroutine create_io_error(this, code, message, unit, filename)
    type(io_error), allocatable, intent(out) :: this
    integer, optional, intent(in) :: code
    character(*), optional, intent(in) :: message
    integer, optional, intent(in) :: unit
    character(*), optional, intent(in) :: filename

    allocate(this)
    call io_error_init(this, code=code, message=message, unit=unit, filename=filename)

  end subroutine create_io_error


  !> Initializes an io_error instance.
  pure subroutine io_error_init(this, code, message, unit, filename)
    type(io_error), intent(out) :: this
    integer, optional, intent(in) :: code
    character(*), optional, intent(in) :: message
    integer, optional, intent(in) :: unit
    character(*), optional, intent(in) :: filename

    call fatal_error_init(this%fatal_error, code=code, message=message)
    if (present(unit)) then
      this%unit = unit
    end if
    if (present(filename)) then
      this%filename = filename
    end if

  end subroutine io_error_init


  !> Destroys an error explicitely (after deactivating it)
  subroutine destroy_io_error(this)
    type(io_error), allocatable, intent(inout) :: this

    if (allocated(this)) then
      call this%deactivate()
      deallocate(this)
    end if

  end subroutine destroy_io_error


  !> Catches an io_error and executes an error handler
  subroutine catch_io_error(error, errorhandler)
    type(io_error), allocatable, intent(inout) :: error
    interface
      subroutine errorhandler(error)
        import :: io_error
        implicit none
        type(io_error), intent(in) :: error
      end subroutine errorhandler
    end interface

    call error%deactivate()
    call errorhandler(error)
    deallocate(error)

  end subroutine catch_io_error


  !> Catches a generic error class and executes an error handler
  subroutine catch_io_error_class(error, errorhandler)
    class(fatal_error), allocatable, intent(inout) :: error
    interface
      subroutine errorhandler(error)
        import :: io_error
        implicit none
        class(io_error), intent(in) :: error
      end subroutine errorhandler
    end interface

    logical :: caught

    if (allocated(error)) then
      caught = .false.
      select type (error)
      class is (io_error)
        call error%deactivate()
        call errorhandler(error)
        caught = .true.
      end select
      if (caught) deallocate(error)
    end if

  end subroutine catch_io_error_class

end module error_extension

Given different extensions of the base type, the patterns to generate and catch the errors change slightly. One would typically use class(fatal_error) variables instead of type(fatal_error). Additionally the select type construct can be used to find out which actual error subclass was thrown. Let's assume that two extending error types io_error and linalg_error had been created, a pattern, which can distinguish between the two would look as [examples/catch_class.f90]:

  class(fatal_error), allocatable :: error

  call routine_throwing_error(..., error)
  call catch_io_error_class(error, handle_io_error)
  call catch_linalg_error_class(error, handle_linalg_error)

contains

  ! Handler for io error
  subroutine handle_io_error(error)
    class(io_error), intent(in) :: error

    print "(2a)", "IO Error found: ", error%message

  end subroutine handle_io_error


  ! Handler for linalg error
  subroutine handle_linalg_error(error)
    class(linalg_error), intent(in) :: error

    print "(2a)", "Linear algebra error found: ", error%message

  end subroutine handle_linalg_error

Alternatively, with manual deactivation and deallocation without explicit error handler routines:

class(fatal_error), allocatable :: error

call routine_throwing_error(..., error)
if (allocated(error)) then
  select type (error)
  class is (io_error)
    call error%deactivate()
    print "(2a)", "IO Error found: ", error%message
  class is (linalg_error)
    call error%deactivate()
    print "(2a)", "Linear algebra error found: ", error%message
  class default
    print "(a)", "Thrown error had not been handled by this block"
  end select
  if (.not. error%is_active()) deallocate(error)
end if

Or in the more compact Fypp-form [examples/catch_class_fypp.fpp]:

class(fatal_error), allocatable :: error

call routine_throwing_error(..., error)
#:block catch_error_class("error")
#:contains io_error
    print "(2a)", "IO Error found: ", error%message
#:contains linalg_error
    print "(2a)", "Linear algebra error found: ", error%message
    print "(a,i0)", "Additional info: ", error%info
#:endblock

When the error is created, it should be converted from the specialized type to the generic type, easily accomplished with a move_alloc() statement:

subroutine routine_throwing_error(error)
  class(fatal_error), allocatable, intent(out) :: error

  type(io_error), allocatable :: ioerr

  call create_error(ioerr, message="Failed to open file", filename="test.dat")
  call move_alloc(ioerr, error)
  return
  print "(a)", "you should not see this as an error was thrown before"

end subroutine routine_throwing_error

When using Fypp, it reduces to

subroutine routine_throwing_error(error)
  class(fatal_error), allocatable, intent(out) :: error

  type(io_error), allocatable :: ioerr

  @:throw_error_class(ioerr, io_error, message="Failed to open file", filename="test.dat")
  print "(a)", "you should not see this as an error was thrown before"

end subroutine routine_throwing_error

About

Robust exception like error handling using modern Fortran

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published