Fortran: Service Composition, part 2: Decoration
Matthias Noback
In the previous post we saw how to use an abstraction to compose an aggregation of services of that same abstraction. There we simply delegated a call, adding no specific behavior. But we might as well add some new behavior before or after delegating the call.
As an example, let’s try to implement a new feature in the logging module: we want to add a timestamp in front of each message, so the output becomes something like this:
2025-06-09T11:19:54+0200 A debug message
We can use a function like current_time()
to generate an ISO 8601-formatted timestamp:
function current_time() result(iso_time)
character(len=8) :: date
character(len=10) :: time
character(len=5) :: zone
character(len=24) :: iso_time
call date_and_time(date, time, zone)
iso_time = date(1:4)//'-'//date(5:6)//'-'//date(7:8)//'T'// &
time(1:2)//':'//time(3:4)//':'//time(5:6)//zone
end function current_time
Exporting it from our logging
module, users could add the timestamp themselves:
call logger%log(current_time()//' '//'A debug message')
Of course, this would lead to a lot of duplication: everywhere we log something, we have to call this function. And it’s likely that we’ll sometimes forget to do so.
An alternative would be to prepend the timestamp in every log()
procedure we currently have, like file_logger_log
(but also stdout_logger_log
):
subroutine file_logger_log(self, message)
class(file_logger_t), intent(in) :: self
character(len=*), intent(in) :: message
write (self%log_file_unit, fmt=*) current_time()//' '//message
end subroutine file_logger_log
This, again, leads to duplicated code. Our code would be in a better shape if we’d have a single place to do this work. One option would be to do it in the log()
procedure of multi_logger_t
, but this gives it a mixed set of responsibilities: pre-processing a log message, and delegating to multiple loggers.
Decorating a service abstraction
The cleanest solution comes from using another type of service composition, called decoration. We define a new concrete type, called timestamp_logger_t
, extending from the abstract logger type:
type, extends(logger_t) :: timestamp_logger_t
private
class(logger_t), allocatable :: inner_logger
contains
procedure :: log => timestamp_logger_log
end type timestamp_logger_t
This type holds another abstract logger_t
type as its data component (inner_logger
) to which it delegates the log()
call. But before invoking the inner logger, it modifies the message by adding the timestamp in front of it:
subroutine timestamp_logger_log(self, message)
class(timestamp_logger_t), intent(in) :: self
character(len=*), intent(in) :: message
call self%inner_logger%log(current_time()//' '//message)
end subroutine timestamp_logger_log
You could say that the timestamp_logger_t
can be “wrapped” around another logger, making it more powerful. It doesn’t know what exact type it wraps, and it doesn’t care either, as long as it can delegate the log()
call. The same goes for the users of the timestamp_logger_t
. They should not care about the fact that the timestamp gets added; they should only rely on being able to call log()
on it.
Again, although we only rely on abstract types in the code, at runtime we need to have a concrete type. And we want to use the timestamp_logger_t
. We only need to modify the code in the get_logger()
abstract factory, and wrap the new timestamp_logger_t
around the existing logger (multi_logger_t
):
if (.not. allocated(shared_logger)) then
shared_logger = timestamp_logger_t( &
multi_logger_t([ &
logger_reference_t(file_logger_t('debug.log')), &
logger_reference_t(stdout_logger_t()) &
]) &
)
end if
The result is that every log message will show the current time in front of it, no exceptions.
Log levels
Let’s consider another form of decoration.
A common pattern in the business of logging is to indicate the so-called log level of each message. Then you can configure the logger to ignore log messages below a certain level. As an example, during normal use we may want to see only warnings or worse. When debugging, however, we may want to see every possible log message, including debug and info messages. We need to make this decision somewhere, but we don’t want to copy the logic several times. Again, service decoration will be the perfect approach.
First we define a set of log level constants (parameter
s). We choose increasing integer values to represent the “severity” of the message. This helps us compare log levels later on. An alternative would be to use an enum type. Fortran doesn’t have these built-in, but they can be replicated. We’ll look at this in another post.
module logging
! ...
integer, parameter, public :: LOG_DEBUG = 0
integer, parameter, public :: LOG_INFO = 1
integer, parameter, public :: LOG_WARN = 2
integer, parameter, public :: LOG_ERROR = 3
integer, parameter, public :: LOG_FATAL = 4
! ...
end module logging
Users should pass the log level when they call log()
. This means we need an additional argument. This represents a BC break - a backward compatibility break; existing calls to log()
need to be modified to support this new feature. A nice way to get compiler support with this operation is to modify the interface of the deferred
procedure log()
of logger_t
:
interface
- subroutine logger_log(self, message)
+ subroutine logger_log(self, message, level)
import logger_t
implicit none(type, external)
class(logger_t), intent(in) :: self
character(len=*), intent(in) :: message
+ integer, intent(in) :: level
end subroutine logger_log
end interface
First you’ll get compiler errors for every existing log()
implementation:
error #8278: An overriding binding and its corresponding overridden
binding must have the same number of dummy arguments. [LOG]
procedure :: log => file_logger_log
-------------------^
We can copy the argument definition from the interface and add the dummy argument to the existing argument list:
-subroutine file_logger_log(self, message)
+subroutine file_logger_log(self, message, level)
class(file_logger_t), intent(in) :: self
character(len=*), intent(in) :: message
+ integer, intent(in) :: level
write (self%log_file_unit, fmt=*) message
end subroutine file_logger_log
When this is done we’ll get some more compiler errors, namely at the call sites:
error #6631: A non-optional actual argument must be present when
invoking a procedure with an explicit interface. [LEVEL]
call logger%log(message)
-----------^
The level
is not an optional argument, so we need to provide a value in all the places where we call log()
. In the cases where we delegate, we should pass the given level
, just as we pass the message
:
subroutine timestamp_logger_log(self, message, level)
class(timestamp_logger_t), intent(in) :: self
character(len=*), intent(in) :: message
integer, intent(in) :: level
- call self%inner_logger%log(current_time()//' '//message)
+ call self%inner_logger%log(current_time()//' '//message, level)
end subroutine timestamp_logger_log
Finally, in user code we have to start passing an explicit log level, to be imported from the logging
module:
use logging, only: get_logger, logger_t, LOG_DEBUG
! ...
call logger%log('A debug message', LOG_DEBUG)
Optional delegation
Finally, we can create another decorating logger. Let’s call it threshold_logger_t
. Besides the inner_logger
instance, it also holds a variable to indicate the minimum log level we are interested in:
type, extends(logger_t) :: threshold_logger_t
private
class(logger_t), allocatable :: inner_logger
integer :: minimum_log_level
contains
procedure :: log => threshold_logger_log
end type threshold_logger_t
The log()
procedure of this new logger decides whether inner_logger%log()
should be called, based on the passed level
argument, and the configured minimum_log_level
:
subroutine threshold_logger_log(self, message, level)
class(threshold_logger_t), intent(in) :: self
character(len=*), intent(in) :: message
integer, 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
This code shows a so-called early return. It’s generally preferable over writing code with increasing levels of indentation:
if (level >= self%minimum_log_level) then call self%inner_logger%log(message, level) end if
When this convention is followed everywhere, functions are easier to read: they start with a number of
if
clauses that represent edge cases and conditions where nothing needs to be done. In the end we can clearly see what the function will do if all the pre-conditions are met.
To use the new threshold_logger_t
, we need to modify the get_logger()
abstract factory once more. We can wrap the logger around the multi_logger_t
instance:
integer :: minimum_log_level
if (.not. allocated(shared_logger)) then
minimum_log_level = LOG_WARN
shared_logger = threshold_logger_t( &
timestamp_logger_t( &
multi_logger_t([ &
logger_reference_t( &
file_logger_t('debug.log') &
), &
logger_reference_t( &
stdout_logger_t() &
) &
]) &
), &
minimum_log_level &
)
end if
The minimum_log_level
is a local variable in this example, but it may be useful to turn it into a private module variable. We should then offer a public subroutine to allow the user to set a log level globally:
module logging
! ...
public :: set_minimum_log_level
integer, parameter, public :: LOG_DEBUG = 0
! ...
integer :: minimum_log_level = LOG_WARN
contains
! ...
subroutine set_minimum_log_level(level)
integer, intent(in) :: level
minimum_log_level = level
end subroutine set_minimum_log_level
end module logging
In the main program we could then set the desired log level once, for instance based on command-line arguments:
program main
use logging, only: logger_t, get_logger, log, &
LOG_DEBUG, LOG_WARN, set_minimum_log_level
implicit none(type, external)
class(logger_t), allocatable :: logger
call set_minimum_log_level(LOG_DEBUG)
logger = get_logger()
call logger%log('A debug message', LOG_DEBUG)
call log('Old-school logging', LOG_WARN)
end program main
Note: we have to call set_minimum_log_level()
before calling get_logger()
, because the factory will copy the current value of minimum_log_level
to the threshold_logger_t
instance and this value can’t be changed. That’s a good thing, by the way, because it makes the service behave more predictably.
We will get back to the topic of global, runtime configuration and also dependency injection in a later post.
Maximum flexibility
In the previous examples we’ve been wrapping loggers at the top level, but the cool thing about relying on abstractions only is that we can also decorate loggers at a lower level in the get_logger()
factory. For example, we can easily introduce different thresholds for logging to a file (LOG_DEBUG
) and to a screen (LOG_WARN
):
shared_logger = timestamp_logger_t( &
multi_logger_t([ &
logger_reference_t( &
threshold_logger_t( &
file_logger_t('debug.log'), LOG_DEBUG) &
), &
logger_reference_t( &
threshold_logger_t( &
stdout_logger_t(), LOG_WARN) &
) &
]) &
)
Using these composition techniques we achieve maximum flexibility in our service design. We also end up with more and smaller blocks of code that each have their own responsibility, making it easer to update something without requiring a modification anywhere in the code. At the moment, the logging
module has become quite big though: 190 lines with multiple derived types and their type-bound procedures. It makes sense to split the logging
module into several smaller modules. We also need to consider the risk of encountering so-called compilation cascades. Let’s do this in the next post.