Fortran: Modeling Services as Derived Types
Matthias Noback
We’ve seen how to define a derived type to represent a point in 2D space. We were able to add a type-bound procedure to it, and finally to make its components private and define a custom constructor for it.
In essence there are two categories of derived types:
- Derived types that hold some data. Their type-bound procedures are solely dealing with this data, and may produce valuable information with it. The point is an example of such a derived type: we can use its data to make geometrical calculations, or we can combine it together with other points into lines, polygons, etc. This all happens in memory. In typical business applications you would consider entities, value objects and data transfer objects (DTOs) to be in this first category.
- Derived types that can perform some task that deals with things external to the application, like a file, the terminal, a network connection, etc. Such a type doesn’t hold data inside. It is able to fetch data, or send/store data. In business applications such a type is often called a service.
In object-oriented programming, service types usually offer an abstraction. This allows the client to decouple from its specific implementation details. For example, take a logger service; it’s often beneficial for the user that they don’t have to be concerned about where the logs are written to (file, screen, log aggregation server, etc.). The user only wants to call a simple log()
function to log something to whatever has been configured at some higher level. Introducing an logger abstraction will take a few posts from here, but it’s good to know that service abstraction is the goal.
Let’s start from a situation where there is such a log()
subroutine already. Before it can be used we shouldn’t forget to call init_logging()
once. This subroutine will open a log file for appending. The logging
module below shows these two subroutines:
module logging
implicit none(type, external)
private
public :: init_logging
public :: log
! Module state; the opened log file unit
integer :: log_file_unit
contains
subroutine init_logging(file_path)
character(len=*), intent(in) :: file_path
integer :: open_status
open (file=file_path, newunit=log_file_unit, &
status='unknown', position='append', &
action='write', iostat=open_status)
if (open_status /= 0) then
error stop "Could not open file "//file_path//" for writing"
end if
end subroutine init_logging
subroutine log(message)
character(len=*), intent(in) :: message
! Assuming `init_logging()` has been called
write (log_file_unit, fmt=*) message
end subroutine log
end module logging
What are file units?
We won’t go into the details of working with files, but as a short introduction: opening a file gives us an
integer
status (it was a successful operation if the status is0
), and a new file unit (also aninteger
). We use this same unit if we later want towrite
to (orread
from) this file.
Although this example shows a common approach, the code has several issues:
- We can only call
log()
if we have calledinit_logging()
first. If we don’t,log_file_unit
will be0
and the message will be logged to the standard terminal output (stdout
). This is surprising and probably unwanted behavior, and it merely “works” accidentally. Such a dependency between function calls is called temporal coupling. It’s not clear that there even is such a coupling. You have to know it, and remember it, or figure it out the hard way. - We use a variable at the module level (
log_file_unit
) to store the file unit. Relying on module state is (in most cases) very impractical, because it’s shared state. If you also want to write to another file, you could callinit_logging()
again with a different file path, but it would overwrite thelog_file_unit
variable.
This situation really calls for a derived type which can absorb both the init_logging()
and log()
behavior, and the log_file_unit
state. That should help us solve these issues at once. We can use all the techniques we’ve seen in previous posts:
- Define the derived type
file_logger_t
with a privateinteger
log_file_unit
data component. - Add a type-bound procedure
log()
to it, which replaces thelog()
subroutine. - Define a factory function
create_file_logger()
for it, which replaces theinit_logging()
subroutine. - Add an
interface
with the name of the type (file_logger_t
), so we can easily construct it outside thelogging
module.
This is what we need to declare at the top of the module:
module logging
implicit none(type, external)
private
! Expose only the type; this includes the factory function
public :: file_logger_t
type :: file_logger_t
private
integer :: log_file_unit
contains
procedure :: log => file_logger_log
end type file_logger_t
interface file_logger_t
procedure create_file_logger
end interface
contains
! ...
The subroutine init_logging()
has been transformed into the factory function create_file_logger
, which returns a file_logger_t
and assigns the file unit to its data component:
function create_file_logger(file_path) result(file_logger)
character(len=*), intent(in) :: file_path
type(file_logger_t) :: file_logger
integer :: open_status
integer :: log_file_unit
open (file=file_path, newunit=log_file_unit, status='unknown', &
position='append', action='write', iostat=open_status)
! ...
file_logger%log_file_unit = log_file_unit
end function create_file_logger
The log()
subroutine has become a type-bound procedure. Its first argument is now self
, which is the file_logger_t
instance on which it’s called. It has access to the data component that stores the file unit:
subroutine file_logger_log(self, message)
class(file_logger_t), intent(in) :: self
character(len=*), intent(in) :: message
write (self%log_file_unit, fmt=*) message
end subroutine file_logger_log
Usage of the new file_logger_t
service looks like this:
use logging, only: file_logger_t
type(file_logger_t), allocatable :: file_logger
file_logger = file_logger_t('debug.log')
call file_logger%log('Something happened')
We first instantiate a file_logger_t
, providing the log file path. This will open the file for appending. Then we call the type-bound log()
procedure. What have we gained?
- We no longer have the temporal coupling issue. When you cal
log()
you can be certain that the file was opened, because this happens in the factory function. - We no longer rely on module state to store the log file unit. Instead, it’s saved in the derived type instance.
This enables the scenario we considered earlier, where we want to write to two different files. This is very easy now:
type(file_logger_t), allocatable :: file_logger_1
type(file_logger_t), allocatable :: file_logger_2
file_logger_1 = file_logger_t('debug.log')
file_logger_2 = file_logger_t('info.log')
call file_logger_1%log('A debug message')
call file_logger_2%log('An info message')
We can create as many loggers as we want…
Can a user really be forced to use the factory function?
With the current situation, a user could still get a
file_logger_t
instance that hasn’t been created by means of the factory function. This means that technically it’s possible to have a file logger without a properlog_file_unit
value. That’s because they can declare a variable without theallocatable
attribute:type(file_logger_t) :: bad_file_logger bad_file_logger%log('Will not log to file...')
The user has “circumvented” the factory, even if they didn’t want to do it. The compiler doesn’t know the factory is required, so you won’t get a compiler error for this. There is a solution, but it requires some more work. We’ll save that for another post.
What’s next?
There are several design issues that still need to be addressed:
- We don’t want to trouble the user with instantiating new loggers everywhere they need one. This would lead to a lot of duplicated code, and a lot of duplicated knowledge about log file names, log levels, etc. If we ever want to change anything about the logic involved, we need to modify it everywhere a logger is used.
- In fact, we don’t want the user to worry about which particular type of logger is needed. They should be offered some kind of abstract logger service, while not worrying about it being a file logger, a terminal output logger, or anything else.
We’ll address these concerns, and several others, in the next post.
Refactoring towards a better design
Say we want to introduce a new logger based on derived types in a large code base that still has calls to the old log()
subroutine everywhere… Then the way we modified the code earlier is not the safest way. It would require updates in many places, and in large (and legacy) code bases we usually don’t want to risk breaking something. On top of that, the current design isn’t definitive yet.
A much safer way of working is to keep the original log()
subroutine, so we don’t have to modify any of the existing code outside this module. This makes it safe to make changes, without breaking the application. We have to make sure that the behavior of log()
doesn’t change, compared to its original behavior. But we still want to use the new code we have (the file_logger_t
derived type and its log()
procedure). The solution is to have a local allocatable
variable that can hold a file_logger_t
instance. We check if it’s allocated and if not, instantiate the logger, which will open the debug.log
file. Note that the variable has the save
attribute, which means that it will not be deallocated automatically at the end of the subroutine. The next time someone calls log()
, it will still hold the same instantiated file_logger_t
instance:
! The `log()` subroutine
subroutine log(message)
character(len=*), intent(in) :: message
type(file_logger_t), allocatable, save :: file_logger
if (.not. allocated(file_logger)) then
! Because of the `save` attribute, this happens only once
file_logger = file_logger_t('debug.log')
end if
! Forward the call to the type-bound procedure `log()`
call file_logger%log(message)
end subroutine log
! The type-bound `log()` procedure of `file_logger_t`
subroutine file_logger_log(self, message)
class(file_logger_t), intent(in) :: self
character(len=*), intent(in) :: message
! Here we do the actual work
write (self%log_file_unit, fmt=*) message
end subroutine file_logger_log
If we make the log
subroutine public
, any other module can still use it, and they won’t notice any difference:
program main
use logging, only: file_logger_t, log
implicit none(type, external)
type(file_logger_t), allocatable :: file_logger
file_logger = file_logger_t('debug.log')
! Using the derived type:
call file_logger%log('A debug message')
! Using the old subroutine:
call log('Old-school logging')
end program main