Fortran - Errors and error handling - Part 3 - Preventing edge cases with types
Matthias Noback
In the previous post we introduced an optional_real_t
type to be the return value of an average
function, because depending on the size of the array of numbers passed to it this function may or may not return a real
value.
What if we could just prevent people from calling the average
function when they don’t have any numbers? Maybe we’re lagging too far behind if we have to verify the size of the array inside the average
function.
If average
remains a function that accepts an array of real
s of any dimension, then we don’t have a way to prevent calling this function with an empty array. But, if we make average
a type-bound procedure, then we can make this procedure only available on a type for which it actually makes sense to call it: a list that has at least one element, also known as a non-empty list. We start with a base type for a real
list called real_list_t
, which may hold any number of real
s (including 0
):
type :: real_list_t
real(kind=wp), dimension(:), allocatable :: numbers
end type real_list_t
We then create a subtype, the non_empty_real_list_t
, which extends the real_list_t
. Assuming that this list isn’t empty and has at least one real
, we can safely give this type a type-bound procedure average
:
type, extends(real_list_t) :: non_empty_real_list_t
contains
procedure :: average => non_empty_real_list_average
end type non_empty_real_list_t
contains
pure function non_empty_real_list_average(self) result(res)
class(non_empty_real_list_t), intent(in) :: self
real(kind=wp) :: res
res = sum(self%numbers)/size(self%numbers)
end function non_empty_real_list_average
Note that the implementation is more or less the “naive” implementation we started with in the first post. It’s the right implementation, because the edge case of numbers
being empty no longer exists. A non-empty list always has at least 1 value so we’ll never risk division by 0
.
Well, of course the name non_empty_real_list_t
isn’t sufficient to guarantee this. We still need to prevent users from instantiating a non_empty_real_list_t
while providing an empty array. After all, they can still do something like this:
type(non_empty_real_list_t) :: empty_list_after_all
allocate(empty_list_after_all%values(0))
Or this:
real(kind=wp), dimension(:), allocatable :: no_numbers
type(non_empty_real_list_t), allocatable :: empty_list_after_all
allocate(no_numbers(0))
empty_list_after_all= non_empty_real_list_t(no_numbers)
To accomplish this, we need a few extra steps:
The numbers
data component should be made private
. That way, users can’t allocate or populate the array manually, outside the module that defines non_empty_real_list_t
:
type :: real_list_t
+ private
real(kind=wp), dimension(:), allocatable :: numbers
end type real_list_t
Users still need to be able to instantiate a non_empty_real_list_t
, so we have to offer an alternative factory/constructor. This should be a public
function that returns a real_list_t
. If the provided array of real
s is not empty, it will return non_empty_real_list_t
.
pure function create_real_list(numbers) result(res)
real(kind=wp), dimension(:), intent(in) :: numbers
class(real_list_t), allocatable :: res
if (size(numbers) == 0) then
res = real_list_t(numbers)
else
res = non_empty_real_list_t(numbers)
end if
end function create_real_list
The user is forced to create a new list by calling the factory function. If they want to calculate the average, they have to do a select type
first, to ensure that they can call that type-bound procedure:
class(real_list_t), allocatable :: list
list = create_real_list(some_numbers)
select type (list)
type is (non_empty_real_list_t)
print *, list%average()
end select
Once we’ve established the type to be non_empty_real_list_t
, we may not want to repeat the select type
statements again. One way to “remember” the resolved type is to continue the work in another subroutine, one that doesn’t have class(real_list_t)
as an argument type, but a concrete type(non_empty_real_list_t)
:
select type (list)
type is (non_empty_real_list_t)
call print_average(list)
end select
contains
subroutine print_average(list)
type(non_empty_real_list_t), intent(in) :: list
! From now on, we know `list` is a `non_empty_real_list_t`
print *, list%average()
end subroutine print_average
Again, we’ve fixed a design problem by extending the type-system. We made it impossible to call average
in a way that is not supported by it. It’s good to know that there are often alternative solutions for dealing with edge cases. We have seen two options so far.
In the next post we’ll look into error values, which offer a way to communicate to the user what went wrong inside a function.