Case Study 1: Auditing a Working Module for Fortran 2023 — Without Breaking It
"Debugging is twice as hard as writing the code in the first place. Therefore, if you write the code as cleverly as possible, you are, by definition, not smart enough to debug it." — Brian Kernighan
Executive Summary
You have inherited a small, working module that sets up the heat plate's initial and boundary temperatures.
It compiles, it runs, and it has three habits that Fortran 2023 could improve: scattered pi/180 conversions
(one with a lurking typo), a boundary "mode" passed as a bare integer, and a merge used as a guard that —
because merge evaluates both of its value arguments — does not actually guard anything. Your job is to
read the code, diagnose which 2023 features apply, and port it to a cleaner form — while keeping it
buildable on the compilers your team actually has today. The lesson is the chapter's discipline made
concrete: adopt what has shipped, describe what has not, and never break a working code to chase a feature.
Skills applied
- Reading
mergecorrectly — it evaluates both branches (§39.1) — and recognizing the guard bug it causes. - Replacing magic-number flags with named constants now, with an eye to enumeration types later (§39.1).
- Using degree-valued trig, or a clean fallback, to kill repeated
pi/180conversions (§39.1). - Separating shipped fixes from coming upgrades, and keeping a portable default (§39.4 honesty).
- Defensive programming and
purefunctions withintent(Chapters 13 and 6), reused here.
Background
The module below is representative of a thousand real ones: correct enough to have survived, crusty enough to deserve a look. Read it before you read the analysis, and try to spot the three problems yourself.
! BEFORE -- works, mostly, but has three smells. (Shown for analysis; not our final code.)
module plate_init
use, intrinsic :: iso_fortran_env, only: dp => real64
implicit none
contains
function edge_temp(i, nx, t_hot, mode) result(t)
integer, intent(in) :: i, nx, mode
real(dp), intent(in) :: t_hot
real(dp) :: t
real(dp) :: ang
ang = 180.0_dp * real(i - 1, dp) / real(nx - 1, dp)
if (mode == 1) then
t = t_hot * sin(ang * 3.14159265_dp / 180.0_dp) ! half-sine hot edge
else if (mode == 2) then
t = t_hot * cos(ang * 3.14159265_dp / 108.0_dp) ! (typo: 108) other profile
else
t = 0.0_dp
end if
end function edge_temp
function safe_ratio(t, span) result(r)
real(dp), intent(in) :: t, span
real(dp) :: r
r = merge(t / span, 0.0_dp, span /= 0.0_dp) ! "guard" against span == 0
end function safe_ratio
end module plate_init
Three smells: a merge guard that isn't one, a magic-number mode, and repeated pi/180
conversions, one of which reads 108. We take them in order of severity.
Phase 1: The merge Guard That Guards Nothing
safe_ratio looks defensive: return t/span when span is nonzero, else 0. It is not. merge is an
ordinary intrinsic function, so Fortran evaluates all of its arguments before merge runs — including
t / span. When span == 0, the division happens anyway, producing Inf or a floating-point exception
before merge ever selects the 0.0_dp branch. The guard is decorative.
The fix that ships today is the explicit if, which genuinely short-circuits:
pure function safe_ratio(t, span) result(r)
real(dp), intent(in) :: t, span
real(dp) :: r
if (span /= 0.0_dp) then ! t/span is formed ONLY when span /= 0
r = t / span
else
r = 0.0_dp
end if
end function safe_ratio
The fix that is coming is the Fortran 2023 conditional expression, which short-circuits in one line —
r = ( span /= 0.0_dp ? t / span : 0.0_dp ) — but which you should not commit until your compiler accepts
it. We note it in a comment and move on. (We also added pure; a mapping like this has no side effects, and
saying so helps the optimizer — Chapter 6.)
The rule to carry away:
mergeis for choosing between two values that are both safe to compute. The moment one branch could be invalid or expensive, you need short-circuiting: aniftoday, a conditional expression once 2023 lands.
Phase 2: The Magic-Number mode
mode == 1, mode == 2 — what are 1 and 2? The caller must remember, the reader must guess, and nothing
stops edge_temp(i, nx, t_hot, 7) from compiling and silently returning zero. The portable modern fix is
named constants, which cost nothing and document the intent:
integer, parameter :: profile_half_sine = 1
integer, parameter :: profile_half_cos = 2
Now select case (mode) with case (profile_half_sine) reads as prose, and a stray 7 falls into
case default. This is the "poor man's enum" — type-safe only by convention. The Fortran 2023 upgrade is a
true enumeration type, where profile_t would be a distinct type and the compiler would reject a bare
integer or a value from an unrelated enumeration. We flag that as the target and keep the named constants as
the shippable form (see §39.1's honesty note on enumeration-type support).
Phase 3: The Repeated pi/180 (and the 108 Typo)
The conversion ang * 3.14159265_dp / 180.0_dp appears twice, and once as / 108.0_dp. Repetition is how
typos survive: four nearly identical lines are exactly where the eye stops checking. Two defenses. First,
compute the angle in degrees and let a degree intrinsic do the conversion, so there is no pi/180 to
mistype:
! With Fortran 2023 degree trig (recent gfortran, -std=f2023):
t = t_hot * sind(ang) ! ang already in degrees; no manual conversion, no 108 to get wrong
Second, if you must stay on a compiler without degree intrinsics, isolate the conversion in one pure
helper so it exists in exactly one place:
pure function sind_portable(deg) result(s)
real(dp), intent(in) :: deg
real(dp) :: s
real(dp), parameter :: pi = 3.14159265358979_dp
s = sin(deg * pi / 180.0_dp)
end function
Either way, the 108 bug becomes structurally impossible: there is no longer a 180 for each call site to
get wrong. This is the point of the exercise — not that degree trig is glamorous, but that removing
repetition removes a class of bug.
Phase 4: The Refactored Module, Compiled
Here is the ported module and a driver, written to compile on any modern gfortran (-std=f2018) so the
team is never blocked. Degree trig and the conditional expression are noted as upgrades in comments, not
committed.
! AFTER -- portable today; 2023 upgrades noted in comments.
module plate_init
use, intrinsic :: iso_fortran_env, only: dp => real64
implicit none
integer, parameter :: profile_half_sine = 1
integer, parameter :: profile_half_cos = 2
real(dp), parameter :: pi = 3.14159265358979_dp
contains
pure function edge_temp(i, nx, t_hot, mode) result(t)
integer, intent(in) :: i, nx, mode
real(dp), intent(in) :: t_hot
real(dp) :: t, ang
ang = 180.0_dp * real(i - 1, dp) / real(nx - 1, dp) ! degrees, 0 .. 180
select case (mode)
case (profile_half_sine)
t = t_hot * sin(ang * pi / 180.0_dp) ! 2023: t_hot * sind(ang)
case (profile_half_cos)
t = t_hot * cos(ang * pi / 180.0_dp) ! one conversion, no 108 typo possible
case default
t = 0.0_dp
end select
end function edge_temp
pure function safe_ratio(t, span) result(r)
real(dp), intent(in) :: t, span
real(dp) :: r
if (span /= 0.0_dp) then ! 2023: ( span /= 0 ? t/span : 0 )
r = t / span
else
r = 0.0_dp
end if
end function safe_ratio
end module plate_init
program audit_demo
use plate_init
use, intrinsic :: iso_fortran_env, only: dp => real64
implicit none
print '(a, f8.3)', 'centre half-sine T = ', edge_temp(3, 5, 100.0_dp, profile_half_sine)
print '(a, f6.3)', 'safe_ratio(80,80) = ', safe_ratio(80.0_dp, 80.0_dp)
print '(a, f6.3)', 'safe_ratio(80, 0) = ', safe_ratio(80.0_dp, 0.0_dp)
end program audit_demo
$ gfortran -std=f2018 -Wall plate_init_after.f90 -o audit && ./audit
centre half-sine T = 100.000
safe_ratio(80,80) = 1.000
safe_ratio(80, 0) = 0.000
Sanity check, by hand. For mode = profile_half_sine, i = 3, nx = 5: ang = 180·2/4 = 90°, and
sin(90° · pi/180) = sin(π/2) = 1, so T = 100·1 = 100.000. safe_ratio(80, 80) = 80/80 = 1.000. And
safe_ratio(80, 0) now returns exactly 0.000 — the if never forms 80/0 — which is the bug we set out
to fix, confirmed. The refactor changed the structure, not the results the good cases produced.
Discussion Questions
- The original
safe_ratiohad probably "worked" for years. Under what run conditions would its latent division-by-zero have stayed invisible, and what does that say about testing guards specifically for their edge case? - We shipped named constants, not enumeration types. If your compiler did support enumeration types, what concrete bug in a caller would the switch newly catch at compile time?
- We added
pureto both functions. Why is that both safe (these functions have no side effects) and useful (what does it let the compiler assume)? (Recall Chapter 6.) - Is there any case where the original
mergeguard would have been fine — i.e., wheremergeis the right tool? (Yes: when both branches are always safe to evaluate. Name such a use.)
Your Turn: Extensions
- Option A (analyze). Grep a real codebase (yours, or an open-source Fortran project) for
merge(. For each hit, decide whether both branches are always safe to evaluate. Flag any that are guards in disguise — you may find a real bug. - Option B (port). Take the
AFTERmodule and produce a second version that usessind/cosdunder-std=f2023, keeping the portable version behind a comment. Confirm both give100.000at the centre. - Option C (design). Replace the two
profile_*constants with a sketch of a Fortran 2023 enumeration typeprofile_t, and write the one-paragraph note you would put in the code explaining why the named constants remain the default for now.
Key Takeaways
mergeevaluates both branches. Amergewhose "safe" branch is meant to avoid an invalid computation is not a guard. Useif(today) or a conditional expression (2023) when you need short-circuiting.- Named constants beat magic numbers now; enumeration types will beat named constants later. Adopt the cheap, portable improvement immediately; keep the type-safe one on your radar.
- Removing repetition removes bugs. The
108typo lived because fourpi/180lines looked alike. Degree intrinsics — or onepurehelper — delete the class of mistake. - Refactor without breaking. Every change here left the good-case outputs identical and stayed buildable on today's compilers. That is the whole art: improve the engineering, preserve the behavior, ship on the compilers you have.