Case Study 2: Building an Extensible Solver Framework
"Every
select typethat decides behavior is a method that has not yet been written."
Executive Summary
Where the first case study read a framework, this one asks you to build one — and to fix a common
mistake along the way. You are given a solver driver that chooses between several stepping schemes with a
large select type block, and asked to make it extensible: a colleague wants to add a scheme without
editing the driver. You will diagnose why the select type design resists that, refactor it into an abstract
solver_t with a deferred step, and add a second scheme to prove the new design accepts extensions
cleanly. Then you will place the polymorphism deliberately — coarse-grained, one dispatch per timestep — so
the abstraction costs nothing where it matters. The result is the optional Project Checkpoint framework, and
the reasoning that produced it.
Skills applied: recognizing the select type design smell (§10.3); designing an abstract type and a
deferred contract (§10.4); type extension to add an implementation (§10.1); dispatch through a class
handle (§10.2); and the coarse-grain performance discipline (§10.5, §10.6).
Background
The inherited driver advances a temperature field and supports two schemes: an explicit diffusion step and a relaxation (averaging) step. The previous author selected between them with the object's type:
! The design we are replacing: behavior chosen by a select type in the driver.
subroutine step_dispatch(scheme, fld)
class(*), intent(in) :: scheme
type(field_t), intent(inout) :: fld
select type (scheme)
type is (heat_scheme_t)
! ... explicit diffusion update inline ...
type is (relax_scheme_t)
! ... averaging update inline ...
class default
error stop 'unknown scheme'
end select
end subroutine
It works, and it is exactly wrong for the goal. Every new scheme means a new type is arm inside this
subroutine — and inside every other subroutine that switches on the scheme. The variation is scattered
across the driver instead of living with each scheme. That is the smell §10.3 warned about: a select type
that implements behavior is inheritance turned inside out.
Phase 1 — Design the Contract
The fix is to invert the dependency. Instead of the driver knowing every scheme, each scheme knows how to step itself, and the driver knows only the interface. That interface is an abstract type with one deferred binding:
module solver_base
use kinds, only: dp
implicit none
private
public :: solver_t
type, abstract :: solver_t
real(dp) :: alpha = 0.0_dp
real(dp) :: dt = 0.0_dp
contains
procedure(step_i), deferred :: step
end type solver_t
abstract interface
subroutine step_i(self, fld)
use heat_types, only: field_t
import :: solver_t
class(solver_t), intent(in) :: self
type(field_t), intent(inout) :: fld
end subroutine step_i
end interface
end module solver_base
The contract is now a single sentence the compiler enforces: a solver is anything that can step a field.
Nothing in solver_base mentions heat, or relaxation, or any scheme. That ignorance is the design's
strength — the base cannot be broken by a new scheme, because it does not know schemes exist.
Phase 2 — Build Two Implementations
Each scheme becomes an extends(solver_t) type that supplies its own step. The explicit diffusion scheme
carries a diffusivity and a timestep; the relaxation scheme carries an over-relaxation factor (with
$\omega = 1$ giving plain neighbour-averaging):
module schemes
use kinds, only: dp
use heat_types, only: field_t
use solver_base, only: solver_t
implicit none
private
public :: heat_solver_t, relax_solver_t
type, extends(solver_t) :: heat_solver_t
contains
procedure :: step => heat_step
end type heat_solver_t
type, extends(solver_t) :: relax_solver_t
real(dp) :: omega = 1.0_dp ! 1.0 = plain Jacobi averaging
contains
procedure :: step => relax_step
end type relax_solver_t
contains
subroutine heat_step(self, fld)
class(heat_solver_t), intent(in) :: self
type(field_t), intent(inout) :: fld
real(dp), allocatable :: u_new(:,:)
real(dp) :: r
integer :: i, j
r = self%alpha * self%dt / fld%dx**2
u_new = fld%u
do j = 2, fld%ny - 1
do i = 2, fld%nx - 1
u_new(i,j) = fld%u(i,j) + r * ( fld%u(i-1,j) + fld%u(i+1,j) &
+ fld%u(i,j-1) + fld%u(i,j+1) - 4.0_dp*fld%u(i,j) )
end do
end do
fld%u = u_new
end subroutine heat_step
subroutine relax_step(self, fld)
class(relax_solver_t), intent(in) :: self
type(field_t), intent(inout) :: fld
real(dp), allocatable :: u_new(:,:)
real(dp) :: avg
integer :: i, j
u_new = fld%u
do j = 2, fld%ny - 1
do i = 2, fld%nx - 1
avg = 0.25_dp * ( fld%u(i-1,j) + fld%u(i+1,j) + fld%u(i,j-1) + fld%u(i,j+1) )
u_new(i,j) = (1.0_dp - self%omega)*fld%u(i,j) + self%omega*avg
end do
end do
fld%u = u_new
end subroutine relax_step
end module schemes
Notice what the refactor accomplished. The two update formulas that were tangled inside one select type
now live in two separate, self-contained procedures, each bound to its own type. A third scheme would be a
third module type with its own step — and, crucially, the driver below would not change a character.
Phase 3 — Drive It Polymorphically
The driver holds a class(solver_t) and steps it. It is written once, against the abstract type, and works
for every scheme. Here it runs the diffusion scheme for three steps and the relaxation scheme for one, on
identical $3\times3$ plates with the top edge held at $100$:
program cs02
use kinds, only: dp
use heat_types, only: field_t
use solver_base, only: solver_t
use schemes, only: heat_solver_t, relax_solver_t
implicit none
class(solver_t), allocatable :: sim
type(field_t) :: plate
integer :: n
! --- explicit diffusion, three steps ---
call setup(plate)
allocate(heat_solver_t :: sim)
sim%alpha = 1.0_dp
sim%dt = 0.1_dp
do n = 1, 3
call sim%step(plate)
end do
print '(a, f8.3)', 'heat solver, interior after 3 steps = ', plate%u(2,2)
deallocate(sim)
! --- relaxation, one step: same interface, one line changed ---
call setup(plate)
allocate(relax_solver_t :: sim) ! the ONLY line that differs
call sim%step(plate)
print '(a, f8.3)', 'relax solver, interior after 1 step = ', plate%u(2,2)
contains
subroutine setup(f)
type(field_t), intent(out) :: f
f%nx = 3; f%ny = 3; f%dx = 1.0_dp; f%dy = 1.0_dp
if (allocated(f%u)) deallocate(f%u)
allocate(f%u(f%nx, f%ny))
f%u = 0.0_dp
f%u(1, :) = 100.0_dp
end subroutine setup
end program cs02
$ gfortran -std=f2018 -Wall kinds.f90 heat_types.f90 solver_base.f90 schemes.f90 cs02.f90 -o cs02 && ./cs02
heat solver, interior after 3 steps = 19.600
relax solver, interior after 1 step = 25.000
The two numbers are hand-computed and physically telling. With $r = \alpha\Delta t/\Delta x^2 = 0.1$, the diffusion scheme creeps the interior point up: $0 \to 10 \to 16 \to 19.6$. The relaxation scheme, with $\omega=1$, sets the interior point to the average of its fixed neighbours in a single step: $0.25\,(100+0+0+0) = 25$ — which is exactly the steady state the diffusion scheme is slowly crawling toward. Two solvers, one interface, the same physics answered at different speeds.
Phase 4 — Place the Polymorphism
The design is extensible; now make sure it is also fast, which means placing the dispatch deliberately. Look
at where class appears and where it does not. The class(solver_t) handle — the polymorphism — lives in
the driver, and its step is dispatched once per timestep. That is coarse-grained: one indirect call
amortized over the entire grid update, utterly negligible even for millions of steps.
Now look inside heat_step and relax_step. There is no class anywhere. fld%u is a plain,
contiguous, monomorphic 2D array; the inner loop runs over the first index, the fast direction for
column-major storage (Chapter 5); nothing is
dispatched per cell. The optimizer is free to inline, unroll, and vectorize the kernel exactly as if no
object orientation existed — because, at the level that matters for speed, none does.
The reasoning that matters: this is §10.5 made concrete. We abstracted the solver (a decision made once) and kept the cells flat (arithmetic done a billion times). Had we instead made each cell a polymorphic object and dispatched a method per cell, the same physics would run with an un-inlinable indirect call in the hottest loop — extensible and slow. The framework's shape puts the indirection where it is free and forbids it where it would be ruinous.
Phase 5 — What You Built, and What It Costs to Extend
Step back and tally the design's properties against the goal. Extensibility: adding a scheme is one new
extends(solver_t) type with a step; the driver and the other schemes are untouched, and the compiler
guarantees the newcomer conforms. Correctness: the deferred contract makes "a solver that forgets to
step" a compile error, not a run-time surprise. Performance: the one dispatch per step is free, and the
kernels are as fast as hand-written non-OOP code. The cost: a little more ceremony than a bare
subroutine — an abstract type, an interface block, the import — justified only because there is genuine
plural variation (more than one scheme) to manage. If there were only ever one scheme, none of this would
be worth it, and §10.5 would tell you to keep the plain module from Chapter 8. The framework earns its
complexity precisely because "one of several solvers" is a real requirement.
Discussion Questions
- The
select typeversion and the abstract-type version compute identical results. Articulate the difference in terms of who must change when a fourth scheme is added. Why is "the driver does not change" the decisive property? relax_solver_tcarries anomegacomponent thatheat_solver_tdoes not. How does the framework accommodate schemes with different parameters behind onestepinterface?- The dispatch is once per timestep here. Describe a redesign that would (wrongly) push it to once per cell, and estimate — in words — the damage, referencing §10.5.
Your Turn: Extensions
- Option A. Add a third scheme,
source_solver_t, whosestepadds a constant source termself%source * self%dtto every interior cell. Confirm you changed only one new module type and oneallocateline in the driver. Hand-compute three steps from a zero interior withsource=50, dt=0.1. - Option B. Give
solver_ta seconddeferredbinding,name(self), returning a short label, and have the driver print which scheme it is running. What must every existing scheme now provide, and how does the compiler make sure you did not forget one? - Option C. Compose a second axis of variation: an abstract
boundary_twith a deferredapply(self, fld), held as a component ofsolver_t, so boundary treatment varies independently of the stepping scheme. Sketch the types. Why is composition (a solver has a boundary) better here than trying to express both axes through inheritance alone?
Key Takeaways
- A
select typethat dispatches behavior scatters variation across the code; an abstract type with adeferredbinding gathers each behavior with its type, so adding an implementation touches nothing else. - The decisive test of an extensible design is who must change to add a case: here, only a new type — never the driver, never the existing schemes.
- Put the polymorphism at the coarse grain (choose a solver once, dispatch per timestep) and keep the kernels monomorphic; you get extensibility and full optimization at the same time.
- OOP earns its ceremony only when there is genuine plural variation to manage. One scheme wants a plain module; "one of several schemes" wants this framework.