Case Study 1: Reading a Model-Selector Framework
"Before you change an inheritance hierarchy, find out what it is protecting you from."
Executive Summary
You have inherited a thermal-conduction code that must compute a material's conductivity from its
temperature, and that must support several models of how conductivity depends on temperature — a constant,
a linear law, and (someday) more. The previous author expressed this with an object-oriented hierarchy: an
abstract conductivity_t with a deferred method, and one concrete extension per model. Your job in this
case study is not to change it but to read it: to trace how a call finds the right model at run time, to
port the equivalent Python design into Fortran so you understand the mapping, and — the real skill — to
judge whether the polymorphism sits where it belongs or where it will cost you. By the end you will be able
to look at any model-selector framework and answer the two questions that matter: what varies here, and how
often does the dispatch fire?
Skills applied: reading a type hierarchy and its deferred contract (§10.4); tracing run-time dispatch
through a class handle (§10.2); the import rule for abstract interfaces (§10.4); porting a Python class
hierarchy to Fortran (§10.2); and the coarse-grain-vs-hot-loop judgment (§10.5).
Background
The code models heat conduction through a material whose thermal conductivity $k$ may depend on temperature. Different materials — and different fidelity levels — call for different laws:
- a constant model, $k(T) = k_0$;
- a linear model, $k(T) = k_0\,(1 + \beta T)$;
- and room to add polynomial or tabulated models later without disturbing the solver.
The author's design is the textbook abstract-type pattern. An abstract conductivity_t promises a single
method — "given a temperature, return a conductivity" — and each model is a concrete extension supplying its
own formula:
module conductivity_models
use, intrinsic :: iso_fortran_env, only: dp => real64
implicit none
private
public :: conductivity_t, constant_k_t, linear_k_t
type, abstract :: conductivity_t
contains
procedure(k_i), deferred :: k ! "conductivity at this temperature"
end type conductivity_t
abstract interface
function k_i(self, temp) result(kval)
use, intrinsic :: iso_fortran_env, only: dp => real64
import :: conductivity_t
class(conductivity_t), intent(in) :: self
real(dp), intent(in) :: temp
real(dp) :: kval
end function k_i
end interface
type, extends(conductivity_t) :: constant_k_t
real(dp) :: k0 = 1.0_dp
contains
procedure :: k => constant_k
end type constant_k_t
type, extends(conductivity_t) :: linear_k_t
real(dp) :: k0 = 1.0_dp, beta = 0.0_dp
contains
procedure :: k => linear_k
end type linear_k_t
contains
function constant_k(self, temp) result(kval)
class(constant_k_t), intent(in) :: self
real(dp), intent(in) :: temp
real(dp) :: kval
kval = self%k0
if (temp < -1.0e30_dp) kval = -1.0_dp ! (never true) keeps `temp` referenced
end function constant_k
function linear_k(self, temp) result(kval)
class(linear_k_t), intent(in) :: self
real(dp), intent(in) :: temp
real(dp) :: kval
kval = self%k0 * (1.0_dp + self%beta*temp)
end function linear_k
end module conductivity_models
Phase 1 — Read the Contract
Start where a reader always should: the abstract type and its deferred binding. conductivity_t has no
components and one deferred method k. That is a pure interface — it says "anything calling itself a
conductivity model must, given a temperature, return a conductivity," and it says nothing about how. The
abstract interface block spells out the exact signature every model must match: a class(conductivity_t)
passed object, a real(dp) temperature in, a real(dp) result out.
Two details reward attention because they are the ones learners trip on. First, the interface body carries
its own use for dp and an import :: conductivity_t: an interface body is a separate scoping unit, so
it does not automatically see the module's kind parameter or type, and it must pull them in explicitly.
Second, the deferred method has no body in the type — the bodies live in the concrete extensions. The
compiler will refuse to compile any concrete extension that fails to provide k, which is exactly the
guarantee you want when you read the code: every model in this program provably answers the conductivity
question.
Phase 2 — Trace the Dispatch
Now follow a call. A user of the framework holds a polymorphic handle and asks it for a conductivity:
class(conductivity_t), allocatable :: model
real(dp) :: k_here
! ... model is allocated to some concrete type ...
k_here = model%k(temp)
The declared type of model is conductivity_t; its dynamic type is whatever it was allocated to. The call
model%k(temp) cannot be resolved at compile time — the compiler does not know which model model holds —
so it emits an indirect call, chosen at run time from model's dynamic type. If model is a
constant_k_t, control lands in constant_k; if a linear_k_t, in linear_k. The calling code names
neither. That is dispatch, and it is the whole point of the hierarchy: the solver that computes heat flux
can be written once, against conductivity_t, and work with every present and future model.
Phase 3 — Port the Python, Confirm the Mapping
The clearest way to be sure you understand the Fortran is to sit it beside the Python it mirrors. The author's design is exactly this:
class Conductivity:
def k(self, temp): raise NotImplementedError
class ConstantK(Conductivity):
def __init__(self, k0): self.k0 = k0
def k(self, temp): return self.k0
class LinearK(Conductivity):
def __init__(self, k0, beta): self.k0, self.beta = k0, beta
def k(self, temp): return self.k0 * (1.0 + self.beta*temp)
The mapping is nearly one-to-one — abstract base to type, abstract; raise NotImplementedError to
deferred; each subclass to an extends type — with one revealing difference: Fortran makes you declare
what Python leaves implicit. You must state that temp is real(dp), intent(in), that the result is
real(dp), that model is class(conductivity_t). Python discovers all of this at run time, every call;
Fortran fixes it at compile time, which is why the compiler can check your models conform and can generate
fast code once the dynamic type is known.
Compile the Fortran with a small driver that exercises both models at $T = 100$:
program cs01
use, intrinsic :: iso_fortran_env, only: dp => real64
use conductivity_models
implicit none
class(conductivity_t), allocatable :: model
real(dp) :: t
t = 100.0_dp
allocate(model, source = constant_k_t(k0 = 2.0_dp))
print '(a, f6.3)', 'constant model k(100) = ', model%k(t)
deallocate(model)
allocate(model, source = linear_k_t(k0 = 2.0_dp, beta = 0.01_dp))
print '(a, f6.3)', 'linear model k(100) = ', model%k(t)
end program cs01
$ gfortran -std=f2018 -Wall conductivity_models.f90 cs01.f90 -o cs01 && ./cs01
constant model k(100) = 2.000
linear model k(100) = 4.000
The constant model returns $k_0 = 2$; the linear model returns $2.0\,(1 + 0.01\cdot 100) = 2.0\cdot 2 = 4.0$ — hand-computed, as always.
Phase 4 — Judge the Placement
Here is where reading turns into engineering. The design is clean, but cleanliness is not the question. The
question is: how often does model%k(temp) fire? Two very different answers are possible, and they lead to
opposite verdicts.
- If the model is chosen once and
kis called once per material region, the dispatch is negligible — a handful of indirect calls in an entire run. The abstraction is free, and the hierarchy is exactly right. - If
model%k(temp)is called inside the per-cell stencil loop, over a million cells, every timestep, then the framework drops an un-inlinable indirect call into the hottest part of the code — a textbook violation of §10.5. The same design that was free in the first reading is now a performance leak.
So the verdict depends entirely on the call site, which the hierarchy itself does not reveal. This is the reading skill the chapter is really teaching: an inheritance hierarchy tells you what varies, but you must find the call frequency yourself before you can say whether the polymorphism belongs there.
The reasoning that matters: if profiling (a skill from Chapter 28) shows
kin the hot loop, the fix is not to abandon the models but to lift the dispatch out of the loop: select the model once, then, for the common constant case, hoist its value to a scalar before the loop; or evaluatekover the whole temperature array in one dispatched call rather than one call per cell. Keep the abstraction at the coarse grain; keep the inner loop monomorphic.
Phase 5 — Report
Your written assessment of the inherited code should say three things. First, the design is sound and
extensible: adding a polynomial or tabulated model means writing one new extends(conductivity_t) type and
touching nothing else — the deferred contract guarantees it will conform. Second, the correctness is
enforced by the compiler, not by convention: no model can silently forget to implement k. Third — and this
is the finding that earns your salary — the design's performance is contingent on where k is called, and
you have located those call sites: the region-level uses are fine, and the one inside the stencil loop
should have its dispatch hoisted out. You have read the framework, understood its guarantees, and found the
one place its shape and its use are at odds.
Discussion Questions
- The abstract
conductivity_thas no components at all. What does that tell you about the author's intent, compared with an abstract type that carries shared data? - Phase 4 hinges on call frequency, which the type hierarchy does not show. What other information about a code can only be recovered by finding the call sites (or by profiling), not by reading the type definitions?
- Porting revealed that Fortran forces declarations Python leaves implicit. Give one concrete bug the Fortran declarations would catch at compile time that the Python would only reveal at run time.
Your Turn: Extensions
- Option A. Add a
tabulated_k_tmodel that stores arrays of temperature/conductivity pairs and interpolates. Where does its data live, and does adding it require any change to code that already usesconductivity_t? - Option B. Instrument the driver to call
model%k(t)in a loop of, say, $10^7$ iterations, and reason (do not benchmark yet — you learn that in Chapter 28) about how much of the run would be dispatch overhead. At what call frequency does the abstraction stop being free? - Option C. Rewrite the whole thing without OOP, as a single function with a
select caseon an integer model id. Compare: what did you gain, what did you lose, and for which call frequency is each design the right one?
Key Takeaways
- An abstract type with a deferred method is a contract: reading it tells you precisely what every implementation must provide, and the compiler enforces conformance.
- Dispatch through a
classhandle resolves at run time to the object's dynamic type; the calling code names no concrete type, which is what makes the framework extensible. - A type hierarchy shows what varies but not how often the dispatch fires — you must find the call sites yourself, and that frequency decides whether the polymorphism is free or a hot-loop leak.
- Porting a design between Python and Fortran is a fast way to understand it: the differences (what Fortran makes you declare) are exactly the guarantees Fortran gives you in return.