Fortran - Errors and error handling - Part 5 - Error propagation
Matthias Noback
Parsing a string to a point may not be successful, and we were able to communicate that to users of parse_point()
by returning a custom type called point_or_error_t
.
What if we want parse not just one point but a set of points, forming a polyline, from a list of strings? The result of parsing each string separately is a point or an error. Only if the list of those point_or_error_t
values contains no error at all should we consider turning the points into a polyline. If any of the values is an error, we shouldn’t make a polyline of the other valid points, but instead return an error: “Could not create polyline from strings. Previous error: Could not extract two decimal numbers from …” With this error we communicate what we were trying to do at the highest conceptual level (create a polyline), and what lower-level error caused this to fail.
First we have to fix an inconvenience in the programming language: if you make an array of strings, all those strings have to be of the same length. But eventually we may be reading those strings from a file, and there each line may have a different length. In practice, Fortran programmers just accept this fact and choose a length that will most likely fit the longest of those strings. But it’s relatively easy to solve the problem using derived types. If we create a string_t
type with a string data component of any length, we can in fact create an array of string_t
values where each of those can hold a string of a different length:
type :: string_t
character(len=:), allocatable :: value
end type string_t
Creating an array of string_t
s looks like this:
type(string_t), dimension(:), allocatable :: strings
strings = [string_t('Short string'), string_t('This is a longer string')]
Before we can use parse_point
to convert a list of these string_t
s, it’s most convenient to upgrade its string
argument to use this new derived type:
pure function parse_point(string) result(res)
- character(len=*), intent(in) :: string
+ type(string_t), intent(in) :: string
type(point_or_error_t) :: res
! ...
- read (string, *, iostat=iostat) x, y
+ read (string%value, *, iostat=iostat) x, y
! ...
end function parse_point
We need one more derived type to represent the concept of a polyline in our code:
type :: polyline_t
type(point_t), dimension(:), allocatable :: points
end type polyline_t
Now we can write the parse_polyline
function that accepts an array of strings and returns a polyline:
pure function parse_polyline(strings) result(res)
type(string_t), dimension(:), intent(in) :: strings
type(polyline_t) :: res
! Use `parse_point` to parse each `string_t` in `strings`
end function parse_polyline
However, since the result of each call to parse_point
is a point_or_error_t
, parse_polyline
should return polyline_or_error_t
, not polyline_t
:
pure function parse_polyline(strings) result(res)
type(string_t), dimension(:), intent(in) :: strings
- type(polyline_t) :: res
+ type(polyline_or_error_t) :: res
! Use `parse_point` to parse each `string_t` in `strings`
end function parse_polyline
This is a new Either type, similar to point_or_error_t
:
type :: polyline_or_error_t
type(polyline_t), allocatable :: polyline
type(error_t), allocatable :: error
end type polyline_or_error_t
Note that I’m not adding type-bound procedures like is_error
, get_error
, etc. The more of these Either types we get, the more pretty much duplicated code we’ll have to maintain, which I don’t think is worth the benefit of encapsulation (normally encapsulation is very useful of course).
Finally, we can write the parse_polyline
function. It’s nice to note that - in functional terms - this contains a filter and two map operations:
pure function parse_polyline(strings) result(res)
type(string_t), dimension(:), intent(in) :: strings
type(polyline_or_error_t) :: res
type(point_or_error_t), dimension(:), allocatable :: points_or_errors
type(point_or_error_t), dimension(:), allocatable :: errors
integer :: i
! Parse all strings
points_or_errors = [(parse_point(strings(i)), i=1, size(strings))]
! Filter the errors
errors = pack(points_or_errors, &
[(allocated(points_or_errors(i)%error), i=1, size(points_or_errors))])
if (size(errors) == 0) then
! Filter the points
points = pack(points_or_errors, &
[(allocated(points_or_errors(i)%point), i=1, size(points_or_errors))])
res%polyline = polyline_t([(points(i)%point, i=1, size(points))])
else
! Add the previous error to the new error message
res%error = error_t('Could not create polyline from strings. '// &
'Previous error: '//errors(1)%error%message)
end if
end function parse_polyline
Note that only the first parse error will be concatenated to the new error message. This is a design choice: do we want to show all parse errors at once, or just the first? We can easily write an alternative. Also note that the current implementation puts all the errors in an array but uses only the first. We can make this a bit more efficient if we like.
The parse_polyline
function can now be used like this:
type(polyline_or_error_t), allocatable :: res
res = parse_polyline([string_t('1.5 2.0'), string_t('1.0 abc')])
if (allocated(res%error)) then
print *, res%error%message
else
! Do something with res%polyline
end if
Note that this indeed produces the desired error message:
Could not create polyline from strings. Previous error:
Could not extract two decimal numbers from 1.0 abc
Nesting errors
Nesting errors is quite a common need in software development, i.e. we often want to “wrap” lower-level errors inside higher-level errors, preserving their full context. However, the way we’ve solved it here is by simply concatenating error messages. What if the lower-level error is a subtype of error_t
and carries some more data that is useful for the programmer or user when analyzing what went wrong, e.g. a file name and line number? This information gets lost the moment we create a new error with just the previous error message concatenated to it.
We can prevent this from happening by adding another data component to error_t
, which can hold not just the previous message but the whole previous error_t
:
type :: error_t
character(len=:), allocatable :: message
+ class(error_t), allocatable :: previous
end type error_t
To allow programmers to define their own specialized subtypes of error_t
, we need to use class(error_t)
, not type(error_t)
wherever we store an error value, e.g. in point_or_error_t
:
type :: point_or_error_t
type(point_t), allocatable :: point
- type(error_t), allocatable :: error
+ class(error_t), allocatable :: error
end type point_or_error_t
Finally, we have to modify parse_polyline
to return the whole error_t
instance, not just its message:
-res%error = error_t('Could not create polyline from strings. '// &
- 'Previous error: '//errors(1)%error%message)
+res%error = error_t('Could not create polyline from strings.', &
+ errors(1)%error)
This helps us preserve the full previous error. Given that each “previous error” has its own previous
data component, we can go on with nesting errors to any depth, allowing errors to “bubble up” from the lowest to the highest level of the application. However, we’re not fully done yet, because when we print the error we only see the last error message, not any of the previous ones:
if (allocated(res%error)) then
print *, res%error%message
! Output: Could not create polyline from strings.
end if
That’s because we print the message
data component, which no longer contains the previous error message. To fix this problem, we can add a type-bound procedure to error_t
, e.g. get_message
, which returns the current message, and concatenates the previous message, and the previous, and so on. This procedure is therefore recursive, calling the same method but on the previous error instance, until it finds an error which doesn’t have an allocated previous
component:
type :: error_t
character(len=:), allocatable :: message
class(error_t), allocatable :: previous
contains
procedure :: get_message => error_get_message
end type error_t
contains
pure recursive function error_get_message(self) result(res)
class(error_t), intent(in) :: self
character(len=:), allocatable :: res
res = self%message
if (allocated(self%previous)) then
res = res//' Previous error: '//self%previous%get_message()
end if
end function error_get_message
Finally, users should stop using message
when they want to print the error message, and instead call the new type-bound procedure get_message()
:
if (allocated(res%error)) then
print *, res%error%get_message()
end if
One more thing to consider: if there are errors, we can’t create a polyline. But what if there are no points? We may not accept a polyline with no points. We’ll look at this problem in the next post.