Fortran: Enumeration, part 2
Matthias Noback
In the previous post we introduced a derived type log_level_t
. The type of data passed to the log()
procedure is still an integer
though:
subroutine log(message, level)
character(len=*), intent(in) :: message
integer, intent(in) :: level
! ...
end subroutine log
This doesn’t stop anyone from passing a completely meaningless integer
value as the level
argument to log()
.
Increasing type-safety
We’d like to increase type-safety and the way to do it is by using a more specific type. In our case, it would be great if we could use log_level_t
as the argument type for level
:
subroutine log(message, level)
character(len=*), intent(in) :: message
- integer, intent(in) :: level
+ type(log_level_t), intent(in) :: level
! ...
end subroutine log
Currently, log_level_t
is just a collection of type-bound procedures that return integer
constants. There is no way we can let it represent a particular log level:
type :: log_level_t
contains
procedure :: debug => log_level_debug
procedure :: info => log_level_info
procedure :: warning => log_level_warning
procedure :: error => log_level_error
procedure :: fatal => log_level_fatal
end type log_level_t
However, we can add a data component to log_level_t
so an instance of it can represent a specific log level:
type :: log_level_t
private
integer :: level
end type log_level_t
We then let the existing procedures return a log_level_t
instance with the respective level integer
value:
function log_level_debug(self) result(res)
class(log_level_t), intent(in) :: self
type(log_level_t) :: res
res%level = 0
end function log_level_debug
function log_level_info(self) result(res)
class(log_level_t), intent(in) :: self
type(log_level_t) :: res
res%level = 1
end function log_level_info
! And so on...
This turns those procedures into little factories.
The good thing is, user code doesn’t need to be updated. We went from passing an integer
as the level
argument, which was provided by procedures like warning()
:
program main
use enumeration, only: log, log_level
call log('A message', log_level%warning())
end program main
Now the argument type is log_level_t
, but warning()
also returns a log_level_t
instance, so everything keeps working. Although nothing changes for the user, we finally have the type-safety we wanted: you can no longer pass any integer
, you have to pass a log_level_t
instance. And you can create those only via the factory functions debug()
, info()
, warning()
, etc.
Even if you try to instantiate a custom log_level_t
, it will be impossible, because the data components are private
, which means the default structure constructor won’t work outside the logging_base
module itself.
Separating log level and log level factory
It’s a bit strange that log_level_t
is now both an actual log level and a factory for all possible log levels. When used as a factory, like when calling warning()
on the public module variable log_level
, the private
data component level
isn’t initialized with a value. And it shouldn’t be, because for the factory it doesn’t make sense to have a specific level. So, the design would improve a lot if we separate the factory from the log level, moving the procedures to a new type log_level_factory_t
.
module logging_base
! ...
+ type :: log_level_factory_t
+ contains
+ procedure :: debug => log_level_debug
+ procedure :: info => log_level_info
+ procedure :: warning => log_level_warning
+ procedure :: error => log_level_error
+ procedure :: fatal => log_level_fatal
+ end type log_level_factory_t
type :: log_level_t
private
integer :: level
- contains
- procedure :: debug => log_level_debug
- procedure :: info => log_level_info
- procedure :: warning => log_level_warning
- procedure :: error => log_level_error
- procedure :: fatal => log_level_fatal
end type log_level_t
- type(log_level_t) :: log_level
+ type(log_level_factory_t) :: log_level
contains
function log_level_debug(self) result(res)
- class(log_level_t), intent(in) :: self
+ class(log_level_factory_t), intent(in) :: self
type(log_level_t) :: res
res%level = 0
end function log_level_debug
! And so on...
end module logging_base
Earlier I mentioned that users didn’t have to change anything in the way they invoke the log()
procedure, but making an argument type more strict is a backward compatibility break anyway. In particular implementers (like ourselves) have to deal with the change. For example, the logging_threshold
module needs some work. At least we have to update the type of the level
argument:
subroutine threshold_logger_log(self, message, level)
class(threshold_logger_t), intent(in) :: self
character(len=*), intent(in) :: message
- integer, intent(in) :: level
+ type(log_level_t), intent(in) :: level
if (level < self%minimum_log_level) then
return
end if
call self%inner_logger%log(message, level)
end subroutine threshold_logger_log
But this still gives us a compiler error:
30 | if (level < self%minimum_log_level) then
| 1
Error: Operands of comparison operator ‘<’ at (1) are
TYPE(log_level_t)/TYPE(log_level_t)
Previously we were able to compare log levels by basic integer
comparison operators like <
. Now we are trying to compare log_level_t
derived types, which normally don’t support those operations.
One way to solve this is to make the level
data component public
again:
-if (level < self%minimum_log_level) then
+if (level%level < self%minimum_log_level%level) then
This would however undo the encapsulation work we’ve done so far, as the level
data component could now be modified again.
To counteract this, we could also provide a procedure get_level
that returns the level as an integer
. That too would undo the encapsulation, because it exposes the underlying, hidden data type we use for log levels. The fact that they are integers under the hood, should really not be relevant to users.
Operator overloading
A better solution is to add a procedure to the log_level_t
so it can compare itself to another instance. This works because private
means module-private. So within the same module instances can retrieve the value of each other’s level
component:
module logging_base
! ...
type :: log_level_t
private
integer :: level
contains
procedure :: is_less_severe_than => log_level_is_less_severe_than
end type log_level_t
contains
pure function log_level_is_less_severe_than(self, other) result(res)
class(log_level_t), intent(in) :: self
class(log_level_t), intent(in) :: other
logical :: res
res = self%level < other%level
end function log_level_is_less_severe_than
end module logging_base
We need to call that procedure instead of comparing level
directly:
if (level%is_less_severe_than(self%minimum_log_level)) then
return
end if
An advantage of this is that we have a nice domain-specific way of talking about log level comparison. The point is not that one is in a numeric sense “less than” the other, it’s that one is “less severe” than the other.
However, it does mean that everywhere we used to compare integer
values, we now have to call this method. If we don’t want that, we can also use a cool technique called operator overloading. This allows us to implement our own logic for operators like <
:
type :: log_level_t
private
integer :: level
contains
procedure :: is_less_severe_than => log_level_is_less_severe_than
+ generic :: operator(<) => is_less_severe_than
end type log_level_t
This syntax means that when the operator <
is used to compare a log_level_t
instance to something else, the compiler will delegate the work to is_less_severe_than()
. You can actually list more than one procedure after the =>
. That way, you can create another procedure that for example compares a log_level_t
to a raw integer
value, or a string representation. The compiler will select the right procedure based on the type of values used in the comparison.
Because we have overloaded the <
operator, we can revert the code to the way it was before we introduced log_level_t
:
if (level < self%minimum_log_level) then
return
end if
Note that a user can still call is_less_severe_than()
if they prefer, unless we make the procedure private
:
- procedure :: is_less_severe_than => log_level_is_less_severe_than
+ procedure, private :: is_less_severe_than => log_level_is_less_severe_than
generic :: operator(<) => is_less_severe_than
Should we use operator overloading? It feels like magic, and it is indeed implicit behavior…
On the one hand, calling an explicit procedure is nice because you can see that it’s being called, and you can immediately go to the procedure definition. This is not the case when you overload the <
operator. Reading the code, you have no clue that it will actually invoke a procedure. You have to consider the type of level
and realize it’s a derived type, and then conclude that it uses operator overloading. (By the way, when step-debugging, you can still “step into” the operator, and you’ll arrive in the type-bound procedure.) On the other hand, using operator overloading is a way of preventing the backward compatibility break in user code. Code that used <
will just keep working.
In other situations, where you deal with mathematical formulas but also want to use derived types, using operator overloading can really simplify the code and will help you keep close to the original mathematical notation.
In summary: we went from integer
log level constants to a log_level_factory_t
producing log_level_t
instances. We still need to explore one other enumeration style, which is an enumeration based on subtypes.