I’ve just started testing the prototype implementation of generics in LFortran. First of all I want to say that I think it’s great that the generics working group is presenting their work openly so that others can test it and provide feedback early in the process!
Also note that I’m just starting to get familiar with the syntax so if anyone thinks I’ve missed something do tell! My goal is to contribute so that Fortran get a quality generics feature, not to critique the working group. I’ll happily change my mind on something if you can show that I’m worried without reason!
I believe I’m seeing some major limitations with the proposal which I fear will severely limit the usefulness of the feature if it goes through as currently suggested.
To demonstrate the shortcomings I will try to implement typical functions where generics are used in other programming languages on a result type similar to the one seen in Rust. The concepts are however not unique to Rust, but commonly seen in most other programming languages with generics support like Java, C++ and functional programming languages like Haskell. The type of functionality I will show here is also commonly used in list- and iterator-like types. See for example map
and for_each
in Java’s Stream
and List
.
Result<T, E>
is a type that is typically returned from a function. It can either hold a value of type T
(upon success) or an error of type E
(upon failure). In pure functional programming languages the same concept is often referred to as an Either
type. In practice an algebraic sum type is usually used to represent this. Fortran doesn’t have these, but the code below is a reasonable approximation:
module result_mod
implicit none
private
public :: result_tmpl
requirement R(T, E)
type :: T; end type
type :: E; end type
end requirement
template result_tmpl(T, E)
requires R(T, E)
type :: result_t
type(T), allocatable :: value
type(E), allocatable :: error
contains
procedure :: is_error
end type
contains
logical pure function is_error(this)
class(result_t), intent(in) :: this
is_error = allocated(this%error)
end function
end template
end module
Here I’ve also added a convenience function that can help us check if the result was an error or not. I could also have added functions or assignment overloads to construct a successful and a failed result_t
, but I’ve omitted this for brevity.
We should be able this like so (note that I’m currently getting an internal error from LFortran on this, but I believe the syntax is correct):
pure function get_answer(chars) result(res)
instantiate result_templ(real, integer), only: result_real_t => result_t
character(len=*), intent(in) :: chars
type(result_real_t) :: res
if (chars == 'fourtytwo') then
res%value = 42.0
else
res%error = -1
end if
end function
Here I’ve used an integer as an error flag. Ideally the error should be a type extending something like error_t
in my error handling library, but that’s a completely separate discussion.
Now let’s try to implement the Rust Result and_then
method. This method takes a function as an argument and if the result holds a value it applies this function to the value and returns it as a new result with the function return type as T
. It is used for applying infallible functions to the value that the result type might hold. If the function that is going to be applied itself is fallible then map
is used. Other programming languages have similar concepts, but might use other names.
Ignoring the generics for a now, we could write it in Fortran like this:
pure function and_then(this, func) result(res)
abstract interface
type(U) pure function func_i(value)
type(T), intent(in) :: value
end function
end interface
class(result_t), intent(in) :: this
procedure(func_i) :: func
type(result_U_E_t) :: res ! We'll ignore how to define this for a while
if (this%is_value()) then
res%value = func(this%value)
else
res%error = this%error
end if
end function
Here we encounter what in my opinion is the first problem with the generics proposal:
Problem 1: Specification of generics (template
) is a separate scope
To implement the function I’ve shown above inside a template we would have to let the template take another type, U
:
module result_mod
implicit none
private
public :: result_tmpl
requirement R(T, E, U)
type :: T; end type
type :: E; end type
type :: U; end type
end requirement
template result_tmpl(T, E)
requires R(T, E)
type :: result_t
type(T), allocatable :: value
type(E), allocatable :: error
contains
procedure :: is_error
procedure :: and_then
end type
contains
logical pure function is_error(this)
class(result_t), intent(in) :: this
is_error = allocated(this%error)
end function
pure function and_then(this, func) result(res)
abstract interface
type(U) pure function func_i(value)
type(T), intent(in) :: value
end function
end interface
class(result_t), intent(in) :: this
procedure(func_i) :: func
type(result_U_E_t) :: res ! We'll ignore how to define this for a while
if (this%is_value()) then
res%value = func(this%value)
else
res%error = this%error
end if
end function
end template
end module
This doesn’t work because U
is not specific to the result_t
type, it is only relevant once we use the and_then
function. When we return a result_t
from a function we simply do not know what transformation the caller might want to apply to it. For different invocations of the same function it might be desirable to use completely different types U
which would not be possible with this design!
What we need in this situation is to have and_then
take a generic type U
which is independent of the type definition of result_t
itself.
Moving on, some might have noticed that the return type of and_then
above is somewhat vaguely defined. This brings on to my next problem:
Problem 2: Generics need to be instantiated explicitly
Since the return type of and_then
itself is a result we need to instantiate the template for its return type. But will it even be possible to instantiate the same template inside itself? To make it work we would need something like this:
template result_tmpl(T, E, U)
requires R(T, E)
type :: result_t
type(T), allocatable :: value
type(E), allocatable :: error
contains
procedure :: and_then
end type
contains
pure function and_then(this, func) result(res)
instantiate result_templ(U, E, X), only: result_U_E_t => result_t
abstract interface
type(U) pure function func_i(value)
type(T), intent(in) :: value
end function
end interface
class(result_t), intent(in) :: this
procedure(func_i) :: func
type(result_U_E_t) :: res ! We'll ignore how to define this for a while
if (this%is_value()) then
res%value = func(this%value)
else
res%error = this%error
end if
end function
end template
Notice the X
in the instantiation statement here. I really don’t know what to put here because it is the return type of and_then
for the new result. There’s no way I can determine what that is a this point.
There are other problems with requiring explicit generics instantiation like this as well:
- It becomes extremely verbose: We probably want to instantiate a
result_templ
for each function we make which will be a lot. We will probably end up instantiating a template for the sameT
andE
multiple times throughout a codebase, but might give them different names. This is very bad for code readability. - Type inference becomes impossible: Since Fortran is a statically typed language it will be possible for the compiler to infer the actual types of generic arguments in many situations. This saves a lot of time and makes code much more concise. This is an example of one of the things that makes Rust stand out from C++. In C++ you either have to make your templates look incredibly complex or the caller must specify all template arguments. Rust on the other hand is much better at inferring generic types based on function arguments which makes it way more pleasant to use generics compared to C++.
A possible solution?
I know the generics working group has been thinking way more about this than me, but I wonder if the solution could be quite simple:
- Assign generic type parameters to existing scopes like
function
,subroutine
,interface
andtype
(and maybemodule
?). Let type bound procedures inherit generic types from their parent. - Move the
requirement
block immediately after the function/subroutine/interface/type declaration. Separate namedrequirement
blocks like the current proposal could also be allowed. - Use angle brackets (
<
and>
) to express generic type parameters right after the name. - Instantiate generic code based on actual arguments. Where generic type parameters cannot be inferred, use angle brackets to specify actual types.
This is pretty much how Java, C++ and Rust does it and I cannot see why it should not work for Fortran. I fear that much of the problems I observe stems from a need to make everything look Fortran-like and unique. If that is the case I would say that it is only natural that languages - spoken languages as well as programming languages - take influence from each other and that is good! Rust inherited a lot of C++ syntax instead of reinventing the wheel. I could also go on about how English historically has been influenced by Old Norse which Norwegian (my first language) is a descendant of and how Norwegian again has been influenced by English in modern times making the circle complete. However, I digress…
With the ideas stated above I believe my result_t
example could be expressed like this:
module result_mod
implicit none
private
public :: result_t
type :: result_t<T, E>
requirement
type :: T; end type
type :: E; end type
end requirement
type(T), allocatable :: value
type(E), allocatable :: error
contains
procedure :: is_error
procedure :: and_then
end type
contains
logical pure function is_error(this)
class(result_t<T, E>), intent(in) :: this
is_error = allocated(this%error)
end function
pure function and_then<U>(this, func) result(res)
requirement
type :: U; end type
end requirement
abstract interface
type(U) pure function func_i(value)
type(T), intent(in) :: value
end function
end interface
class(result_t), intent(in) :: this
procedure(func_i) :: func
type(result_t<U, E>) :: res
if (this%is_value()) then
res%value = func(this%value)
else
res%error = this%error
end if
end function
end module
And in use:
pure function get_answer(chars) result(res)
character(len=*), intent(in) :: chars
type(result_t<real, integer>) :: res
if (chars == 'fourtytwo') then
res%value = 42.0
else
res%error = -1
end if
end function