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_factory_debug
+ procedure :: info => log_level_factory_info
+ procedure :: warning => log_level_factory_warning
+ procedure :: error => log_level_factory_error
+ procedure :: fatal => log_level_factory_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)
+ function log_level_factory_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.