Fortran - Errors and error handling - Part 4 - Using an Either type
Matthias Noback
We looked at optionally returning a value from a function. In the case of average
it was an average value, or no value at all. In some more elaborate functions, the “no result” case needs some more explanation to the caller of the function. We may feel the need to communicate what went wrong, or for what reason. This gives the user the opportunity to pass better input next time, or fix some external issue that the program can’t fix itself (e.g. a file not being readable).
In many other languages the standard solution for this problem is to throw an exception, with an error message describing the problem. An exception is a type that can be extended with custom data fields, allowing you to add more contextual information to the error. As mentioned, in Fortran we don’t have exceptions. We can stop
(or error stop
) the program whenever we like, but we should only do that in the main program
block, not inside procedures. We’ll talk about fatal errors in another post. For now, we’ll deal with non-fatal errors only.
Let’s consider the example of a function that takes a string value from the user (maybe from a text file) that is supposed to contain two decimal numbers, e.g. '1.5 2.0'
. We want to parse such a string into a point_t
derived type which has real
x
and y
components. An initial implementation of such a function would look like this:
pure function parse_point(string) result(res)
character(len=*), intent(in) :: string
type(point_t), allocatable :: res
integer :: iostat
real(kind=wp) :: x
real(kind=wp) :: y
read (string, *, iostat=iostat) x, y
if (iostat /= 0) then
! Failure...
else
res = point_t(x, y)
end if
end function parse_point
The read
statement does the actual job of converting a string into two real
s. This function more or less works:
print *, parse_point('1.5 2.0')
! Output: 1.50000000000000 2.00000000000000
However, if we do:
print *, parse_point('1.5 abc')
We get:
forrtl: severe (174): SIGSEGV, segmentation fault occurred
That’s because we try to print
the return value of parse_point
which is allocatable
, but it hasn’t been allocated because read
failed. So we’re trying to access memory that is not available to us.
How can we communicate what went wrong inside this function? Without choosing one of the inferior options described in part 1 of course.
Can we use an optional return value here, i.e. an optional_point_t
? In fact, here it would be nice to return an error to the user, instead of just no_point_t
. We can tell them that we couldn’t extract two decimal numbers from the string '1.5 abc'
.
Returning either a point or an error
In the same way that average
returned either a real
or nothing, here we can say parse_point
either returns point or an error. Given that we don’t want multiple return values (abusing intent(out)
arguments), we should aim to implement the “either” aspect of this return value using a single type: point_or_error_t
. To leverage the point_t
type we already have, we make point
an allocatable
data component of point_or_error_t
, since it may or may not be defined, based on the string
value provided to parse_point
:
type :: point_or_error_t
type(point_t), allocatable :: point
end type point_or_error_t
We let parse_point
return this point_or_error_t
type:
pure function parse_point(string) result(res)
character(len=*), intent(in) :: string
- type(point_t), allocatable :: res
+ type(point_or_error_t) :: res
! ...
else
- res = point_t(x, y)
+ res%point = point_t(x, y)
end if
end function parse_point
We still need a way to return an actual error message from parse_point
. One idea would be to add an allocatable
string error_message
to point_or_error_t
:
type :: point_or_error_t
type(point_t), allocatable :: point
+ character(len=:), allocatable :: error_message
end type point_or_error_t
We could then set the error message in case of failure:
pure function parse_point(string) result(res)
! ...
if (iostat /= 0) then
+ res%error_message = 'Could not extract two decimal numbers from '//string
else
res%point = point_t(x, y)
end if
end function parse_point
However, it’s smart to do some extra work now and promote the rather primitive string type for error_message
to its own reusable error_t
type, which contains a message
data component:
+type :: error_t
+ character(len=:), allocatable :: message
+end type error_t
type :: point_or_error_t
type(point_t), allocatable :: point
- character(len=:), allocatable :: error_message
+ type(error_t), allocatable :: error
end type point_or_error_t
That way, the two result types (point_t
and error_t
) are both derived types, and they can evolve on their own in any way necessary, which is not the case when we use primitive/scalar types for these values. In the next post we’ll look at some other nice ways in which we can evolve the error_t
type even further.
The parse_point
function should still be modified to reflect the change in point_or_error_t
:
if (iostat /= 0) then
- res%error_message = 'Could not extract two decimal numbers from '//string
+ res%error = error_t('Could not extract two decimal numbers from '//string)
else
res%point = point_t(x, y)
end if
For the user of this function, the point_or_error_t
is a bit harder to work with than optional_real_t
, because we don’t use subtypes here. The way to distinguish between a point or an error is to check if either of these values has been allocated:
type(point_or_error_t), allocatable :: res
res = parse_point('1.5 abc')
if (allocated(res%point)) then
print *, res%point
else if (allocated(res%error)) then
print *, 'Error: ', res%error%message
! Output:
! Error: Could not extract two decimal numbers from 1.5 abc
end if
Just as with the optional_real_t
result, using a point_or_error_t
return type now results in a single return value for the user to deal with. Still, the design is quite different, because with point_or_error_t
we don’t have a type hierarchy. We could introduce that (i.e. have a point_or_error_error_t
and a point_or_error_point_t
, but that might be overhead we don’t really need or want.
Enforcing either of the data components to be provided
One thing that can go wrong with the allocatable
data components point
and error
of point_or_error_t
is that they are both allocated. That would be a mistake, for sure, but we don’t protect the user against this. A possible solution could be to take control over how the return value gets instantiated, like we did with non_empty_real_list_t
. However, users won’t need to instantiate point_or_error_t
outside the module that parses points, because it’s such a specific type.
What we may want to prevent is users (accidentally) modifying a point_or_error_t
instance, or approaching it in the wrong way, e.g. directly accessing one of its data components that isn’t allocated. This means, again, that we should make the data components private
. As a user we still need access to the data. We basically need ways to ask: is it an error? Then get me the error. If it isn’t, then get me a point. These questions can be translated into type-bound procedures. If is_error()
returns .true.
, we should be able to get an error_t
from it, and if is_point()
returns .true.
, get_point()
should return a point_t
. This is the contract of point_or_error_t
. We need quite some code to make this work:
type :: point_or_error_t
private
type(point_t), allocatable :: point
type(error_t), allocatable :: error
contains
procedure :: is_error => point_or_error_is_error
procedure :: get_error => point_or_error_get_error
procedure :: is_point => point_or_error_is_point
procedure :: get_point => point_or_error_get_point
end type point_or_error_t
contains
pure function point_or_error_is_error(self) result(res)
class(point_or_error_t), intent(in) :: self
logical :: res
res = allocated(self%error)
end function point_or_error_is_error
pure function point_or_error_get_error(self) result(res)
class(point_or_error_t), intent(in) :: self
type(error_t), allocatable :: res
res = self%error
end function point_or_error_get_error
! And so on...
Users can’t access the private
components directly anymore:
type(point_or_error_t), allocatable :: res
type(error_t), allocatable :: error
res = parse_point('1.5 abc')
if (res%is_point()) then
print *, res%get_point()
else if (res%is_error()) then
error = res%get_error()
print *, 'Error: ', error%message
end if
An advantage of this approach is that it encapsulates the logic used to determine if the result is a point or an error. Users don’t have to use the low-level allocated
check. However, all this additional code may also be considered too much maintenance overhead. That’s why, in my opinion, it’s a valid option to stick with two public
, allocatable
data components point
and error
.
In the next post, we’ll deal with catching errors at higher levels, and nesting them.