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 k is 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 k in 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 evaluate k over 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

  1. The abstract conductivity_t has no components at all. What does that tell you about the author's intent, compared with an abstract type that carries shared data?
  2. 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?
  3. 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_t model 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 uses conductivity_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 case on 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 class handle 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.