Case Study 2: Designing a Forward-Compatible Config Module for the Solver

"The best way to predict the future is to invent it." — Alan Kay

Executive Summary

You want your heat solver to adopt Fortran 2023 — enumeration types for its configuration, conditional expressions in its logic — but you also have to ship code that builds on the compilers your collaborators have installed today, several of which do not support those features yet. These goals seem opposed. They are not, if you design for the migration in advance: put every choice that a 2023 feature will one day improve behind a small, single-purpose interface, so that flipping the feature on later is a local edit, not a scavenger hunt. In this study you design a solver_config module that is clean and portable now and deliberately shaped so that enumeration types and conditional expressions drop in with minimal churn. Then you build it with fpm and run it in the browser Playground — proving the new tooling as you prepare for the new language.

Skills applied

  • Designing an API around choice points so future features (enumeration types, conditional expressions) localize to one place (§39.1, §39.4).
  • Separating shipped from coming, in code structure, not just in comments (§39.4 honesty).
  • Packaging with fpm and running in the Playground / LFortran (§39.3; Chapter 16).
  • Derived types with sensible defaults and pure mapping functions (Chapters 9 and 6).

Background

The solver's run is governed by a handful of choices: which time-stepping scheme, which boundary condition, what timestep. Today you encode each as an integer flag. Tomorrow you would like each to be a distinct, compiler-checked enumeration type, so that cfg%scheme = bc_neumann — assigning a boundary value to a scheme field — is a compile error rather than a silent 2 that runs the wrong integrator.

The design question is not "which is better" (the enumeration type, clearly) but "how do I build the integer-flag version so that the enumeration-type version is a small, safe change later?" That is a software-engineering question, and answering it well is what makes this a design case study rather than a syntax tour.

Phase 1: The Config Type, With Choice Points Named

Start from named constants and a derived type with defaults. The constants are grouped and commented with their future type name, so the migration target is written down in the code:

module solver_config
  use, intrinsic :: iso_fortran_env, only: dp => real64
  implicit none
  private
  public :: config_t, scheme_euler, scheme_rk4, bc_dirichlet, bc_neumann
  public :: scheme_name, bc_name, default_config

  ! Future: enumeration type `scheme_t`. Named constants until compilers support it.
  integer, parameter :: scheme_euler = 1
  integer, parameter :: scheme_rk4   = 2

  ! Future: enumeration type `bc_t`.
  integer, parameter :: bc_dirichlet = 1
  integer, parameter :: bc_neumann   = 2

  type :: config_t
     integer  :: scheme = scheme_euler     ! future: type(scheme_t)
     integer  :: bc     = bc_dirichlet     ! future: type(bc_t)
     real(dp) :: dt     = 0.01_dp
  end type config_t

The private/public discipline from Chapter 8 matters here: callers see the constants and the type, not the raw integer 1/2, so when those constants become enumerators of a real type, the callers barely change.

Phase 2: One Place to Migrate

Every point where the integer flag is interpreted — turned into a name, a branch, a dispatch — is a place a 2023 feature will touch. Concentrate them. Here two pure mapping functions are the only code that reads the flags; everything else passes them around opaquely:

contains
  pure function scheme_name(s) result(name)
    integer, intent(in) :: s
    character(len=:), allocatable :: name
    select case (s)
    case (scheme_euler)
       name = 'euler'
    case (scheme_rk4)
       name = 'rk4'
    case default
       name = 'unknown'          ! future enumeration type makes this unreachable
    end select
  end function scheme_name

  pure function bc_name(b) result(name)
    integer, intent(in) :: b
    character(len=:), allocatable :: name
    select case (b)
    case (bc_dirichlet)
       name = 'Dirichlet'
    case (bc_neumann)
       name = 'Neumann'
    case default
       name = 'unknown'
    end select
  end function bc_name

  pure function default_config() result(cfg)
    type(config_t) :: cfg
    ! all defaults come from the type definition; override at the call site
  end function default_config
end module solver_config

Note the payoff already visible in the comment: once scheme is a real enumeration type, the compiler guarantees s is one of the enumerators, and the case default becomes provably unreachable — the "unknown" path exists today only because a bare integer could be anything. The design makes the future improvement's benefit legible now.

Phase 3: Build It With fpm, Run It in the Browser

Package the module the fpm way you learned in Chapter 16: library code in src/, the program in app/, a test in test/.

solver-config/
├── fpm.toml
├── src/
│   └── solver_config.f90
├── app/
│   └── main.f90            program show_config (the driver below)
└── test/
    └── test_config.f90     checks scheme_name(scheme_rk4) == 'rk4'
name = "solver-config"
[build]
auto-executables = true

The driver, portable under -std=f2018:

program show_config
  use solver_config
  use, intrinsic :: iso_fortran_env, only: dp => real64
  implicit none
  type(config_t) :: cfg

  cfg = default_config()
  cfg%scheme = scheme_rk4        ! reads as a name, not a magic 2
  cfg%bc     = bc_neumann

  print '(a, a)',    'scheme = ', scheme_name(cfg%scheme)
  print '(a, a)',    'bc     = ', bc_name(cfg%bc)
  print '(a, f5.2)', 'dt     = ', cfg%dt
end program show_config
$ fpm run
scheme = rk4
bc     = Neumann
dt     =  0.01

Sanity check, by hand. default_config() sets dt = 0.01; we override scheme to scheme_rk4 (the constant 2), which scheme_name maps to 'rk4', and bc to bc_neumann, which bc_name maps to 'Neumann'. dt prints under f5.2 as 0.01. Outputs match.

Now prove the tooling: paste the module and driver into the fortran-lang Playground in your browser and press run — no fpm, no local compiler — or load them into LFortran and evaluate the driver interactively. You have just run your solver's configuration layer through two tools that did not exist for Fortran a few years ago, which is the ecosystem half of "the language is alive."

⚠️ Keep it honest. The fpm.toml keys and any Playground/LFortran specifics evolve quickly; confirm them against the current fortran-lang sites rather than trusting a snapshot. The design — choice points behind small interfaces — is what will not go stale.

Phase 4: The Readiness Checklist

Because you designed for migration, "turn on Fortran 2023" becomes a short, auditable checklist rather than a rewrite. Write it into the project so the next maintainer knows the plan:

When your compiler supports… Change, localized to… Fallback kept?
Enumeration types the constant groups + the two field declarations in config_t yes — named constants stay until every target compiler is ready
Conditional expressions any if/else inside a single expression (none critical here yet) yes — the if form still compiles
Degree trig the solver's geometry module (not this config) yes — the pi/180 helper

Each row touches one place, because Phase 1 and Phase 2 put it there. That is the entire return on the design work: the cost of adopting the future was paid down in advance, in structure.

Discussion Questions

  1. We made scheme_name/bc_name the only readers of the flags. What kinds of bugs does concentrating interpretation like this prevent, beyond the migration convenience?
  2. The case default → 'unknown' branch is "dead" once enumeration types arrive. Is it wasteful to write it now? Argue both sides. (Consider: what does a bare integer field permit today?)
  3. Why keep the named-constant fallback after your own compiler supports enumeration types? (Whose compilers must you also build on?) Tie this to the reproducibility discipline of Chapter 37.
  4. Where would a conditional expression naturally appear once the solver's stepping logic is added to this config layer, and how would you keep that line portable in the meantime?

Your Turn: Extensions

  • Option A (build). Complete the fpm project: write test/test_config.f90 that fails (error stop) if scheme_name(scheme_rk4) /= 'rk4', and run fpm test. You now have a regression test guarding the mapping.
  • Option B (design). Add a third choice — the output format (text, vtk) — as a new named-constant group and mapping function, following the same pattern. Notice how mechanical it is: that repeatability is the sign of a good design.
  • Option C (forecast). Rewrite the config_t field declarations as you believe Fortran 2023 enumeration types would spell them, clearly commented as unverified, then check your guess against your compiler's documentation. Where were you right, and where did the real syntax surprise you?

Key Takeaways

  • Design for the migration, not just the feature. Put every choice a future feature will improve behind a small interface; adopting the feature then becomes a local, low-risk edit.
  • Structure encodes honesty. "Shipped vs coming" is not only a comment — it is named constants now with a documented enumeration-type target, and one place to change when the time comes.
  • Prove the tooling as you go. fpm builds it; the Playground and LFortran run it with nothing installed. The ecosystem is part of the language's aliveness, and it is usable today.
  • The future is cheaper if you invent it deliberately. A little design now buys a painless upgrade later — which is exactly the bet the standards committee makes for the whole language.