Generics¶
From a high-level perspective generics are supported in LFortran by three main elements:
Requirements declaring deferred types and their abstract functions
Templates implementing generic symbols (functions, subroutines, derived types) using deferred types and abstract functions described by requirements
Instantiations by passing concrete types and functions into templates
Requirements¶
Requirements declare deferred types (generic types) and its associated functions, similar to typeclasses in Haskell and traits in Rust. For example, the signature for a generic monoid of any type can be represented by the following requirement:
requirement monoid(T, op)
! declaring a deferred type (generic type)
type, deferred :: T
! declaring a function associated with the deferred type
function op(x, y) result(z)
type(T), intent(in) :: x, y
type(T) :: z
end function
function empty() result(z)
type(T) :: z
end function
end requirement
The monoid
requirement declares a deferred type T
and two functions,op
that takes arguments of type T
and return a value with the same type T
and empty
that returns a value of type T
. The deferred type T
is internally interpreted as a variable T
typed as TypeParameter T
(for brevity we will write it just as T
). Functions declared in requirements are abstract (without function body).
On ASR level, the requirement is represented by the symbol Requirement
. ASR for requirements are built during symbol table visit in the function visit_Requirement
. monoid
's symbol table would contain a variable T
with the type T
, a function op
with the type T * T -> T
, and a function empty
with the type () -> T
.
A requirement is checked to see if all the parameters have a corresponding symbol declared inside of it. A warning is generated if a parameter is declared but no symbol is found with the same name.
On its own, a requirement does not do any computation. It is also not compiled into the target language when the ASR is compiled.
Templates¶
Templates take the role of a scope for generic functions. A template uses generic types and its associated type signatures obtained from requirements to check generic operations.
As a running example, we will consider a generic function for n-times multiplication for argument of any type. This function can be represented as:
module generics_example
! same requirement as before
requirement monoid(T, op, empty)
...
end requirement
! the template starts from here
template array_t(S, op_temp, empty_temp)
require :: monoid(S, op_temp, empty_temp)
contains
! below is the generic function
function array_sum(arr) result(r)
type(S), intent(in) :: arr(:)
type(S) :: r
integer :: n, i
n = size(arr)
r = empty_temp(0)
if (n > 0) then
r = arr(1)
do i = 2, n
res = op_temp(r, arr(i))
end do
end if
end function
end template
end module
The template array_t
contains the generic function array_sum
that takes an array arr
of type S
. The template later on will be added to the parent symbol table as a Template
symbol with the name array_t
.
Typing context has to be obtained to type the parameter arr
and the addition operation op_temp(r, arr(i))
. Such typing context can be made available within the scope of the template by using a requirement. Here, the Require
statement require :: monoid(S, op_temp, empty_temp)
builds the types of the template's parameters S
, op_temp
, and empty_temp
based on the types of the symbols in monoid
.
This corresponds to the visit_UnitRequire
that can be found in visit_Template
during symbol table visit. Calling a require statement copies the symbols from the requiremement and replaces their names with the argument names given by the require statement. In this case, the symbol T
, op
, and empty
from the requirement monoid
are replaced by S
, op_temp
, and empty_temp
that are passed as arguments. This replacement is done by the function rename_symbol
.
Eventually the require statement adds into array_t
's symbol table two symbols, S
as a variable with type S
, op_temp
and empty_temp
as functions with types S * S -> S
and () -> S
based on monoid
's symbol table.
Symbol table visit checks the variable declarations in array_sum
. Since S
is available in the symbol table now, both variables arr
and r
can be typed. Later during body visit, with type S
, op_temp
, and empty_temp
in the symbol table, the function call op_temp(r, arr(i))
in array_sum
would be checked in the same way as non-generic functions.
ASR representation of templates are also not compiled into the target language.
Instantiations¶
Generic functions need to be instantiated with concrete types and functions to be used in run-time. The process of instantiation replaces the generic types in the function definition with concrete types (such as integer
, real
) and replace the abstract functions with implemented functions.
The generic function described in the previous section can be instantiated as a function that computes array sum by the following instantiation:
module functions
public :: add_integer, empty_integer
contains
function add_integer(x, y)
integer, intent(in) :: x, y
integer :: add_integer
add_integer = x + y
end function
function empty_integer()
integer :: empty_integer
empty_integer = 0
end function
end module
program instantiate_template
use functions, only: add_integer, empty_integer
instantiate array_t(integer, add_integer, empty_integer), &
only: array_sum_integer => array_sum
! function argument for basic arithmetic operations can be replaced with an operator
instantiate array_t(integer, operator(+), empty_integer), &
only: array_sum_integer => array_sum
! eliding function renaming would instantiating all generic functions with the same name
instantiate array_t(integer, operator(+), empty_integer)
end program
This instantiation statement
instantiate array_t(integer, add_integer, empty_integer), only: array_sum_integer => array_sum
passes the type integer
, an integer addition function add_integer
, and a function describing empty integer value empty_integer
as arguments to template array_t
, then instantiates the function array_sum
as a new function named array_sum_integer
. This instantiation wants to replace S
in array_t
with the type integer
, op_temp
function calls with add_integer
function calls, and empty_temp
function calls with empty_integer
function calls.
Type Checking¶
Before a function is generated on ASR level by an instantiation, the compiler checks the consistency of its type substitution based on the given symbol arguments. Currently there is no notion of subtyping in LFortran, so checking is limited to exact type checks. This is done by tracking the type substitutions made by the symbol arguments and rejecting any contradicting type subsitutition. Checking is done during symbol table visit in visit_Instantiate
.
We will explain this in detail through the instantiation example above. The first argument integer
substitutes the type parameter S
in template array_t
(maps S
to integer
). The second argument add_integer
substitutes the type S * S -> S
of op_temp
with integer * integer -> integer
(checks S
substitution). This is allowed because the function argument gives a consistent substitution with the previous argument. If it passes the type checks, the instantiated function would be generated as a function on ASR level. Successfully checked symbol arguments are put inside the variable type_subs
for type substitution (S
to integer
) and symbol_subs
for function substitution (op_temp
to add_integer
).
Now, suppose that we have the following instantiation where instead of integer
, we pass real
as an argument:
instantiate array_t(real, add_integer, empty_integer), only: array_sum_integer => array_sum
Here, the first type argument maps S
to real
but the second function argument maps S
to integer
, resulting in a contradiction. Hence ending in a type error.
This check is tracked by the two variables type_subs
and symbol_subs
. Function arguments are checked by the function check_restriction
during symbol table visit.
Handling Operator Arguments
Instantiations can be made simpler for basic binary operations (addition, multiplication, etc.) by passing operator(<sign>)
instead of pre-defined functions.
If the type substitution tracked by type_subs
is consistent for a binary arithmetic operation, then a function is implicitly generated as a function argument. From the example before, we will have a function that is equivalent to the following LFortran function generated implicitly:
function ~add_intrinsic(arg0, arg1) result(ret)
integer, intent(in) :: arg0, arg1
integer :: ret
ret = arg0 + arg1
end function
ASR Generation¶
Instantiating the function as a function on the ASR level is simply a symbol replacement process.
The generation process is split into two, generating the function signature during symbol table visit and generating the function body during body visit. The whole generation is not done during body visit because derived types generated from a template may be used to type variable declarations.
During symbol table visit, after checking arguments and placing the symbol substitution into type_subs
and symbol_subs
, the instantiated function is added into the symbol table and its signature is built by the function instantiate_symbol
where the deferred types are replaced with concrete types. For the example above, a function equivalent to the following is generated:
elemental function array_sum_integer(arr) result(res)
integer, intent(in) :: arr(:) ! type replaced
integer :: r ! type replaced
integer :: n, i
end function
The substitution in type_subs
and symbol_subs
are preserved and then passed to the body visitor. During body visit, the body of the instantiated function is built in visit_Instantiate
by the function instantiate_body
, replacing any abstract functions with concrete functions:
function array_sum_integer(n, a) result(res)
integer, intent(in) :: arr(:)
integer :: r
integer :: n, i
n = size(arr)
r = empty_integer(0) ! function call replaced
if (n > 0) then
r = arr(1)
do i = 2, n
res = add_integer(r, arr(i)) ! function call replaced
end do
end if
end do
end function
See Also¶
Programming With Generics, for simpler explaining about using generics in LFortran