Case Study 1: Refactoring a Monolith
"You do not understand code until you can take it apart and put it back together without changing what it does."
Executive Summary
You will inherit far more Fortran than you write, and much of it arrives as a monolith: one long program
with every computation inlined, no procedures, and no intent in sight. This case study takes exactly such
a program — a small field-statistics kernel written as a single block of loops — and refactors it into clean
procedures, adding an intent to every argument and passing its array as assumed-shape, all while proving
that the output does not change by a single digit. That last discipline — refactor without altering
behavior, verified by comparing output — is the professional skill this study teaches, and it is the same
move you will make at industrial scale when you modernize legacy code in
Part IV.
Skills applied: distinguishing functions from subroutines (§6.1); adding intent to every argument
(§6.2); recognizing pure computations (§6.4); converting inline array work to assumed-shape procedures
(§6.6); regression-checking a refactor against a reference output.
Background
Here is the code you have been handed — field_stats_v0. It reads a small array of temperatures, computes
the mean, computes the maximum deviation from the mean, and then centers the data by subtracting the mean.
Everything is inlined into the main program.
program field_stats_v0
use, intrinsic :: iso_fortran_env, only: dp => real64
implicit none
real(dp) :: t(5) = [10.0_dp, 20.0_dp, 30.0_dp, 40.0_dp, 50.0_dp]
real(dp) :: mean, maxdev
integer :: i
mean = 0.0_dp
do i = 1, 5
mean = mean + t(i)
end do
mean = mean / 5.0_dp
maxdev = 0.0_dp
do i = 1, 5
maxdev = max(maxdev, abs(t(i) - mean))
end do
do i = 1, 5
t(i) = t(i) - mean
end do
print '(a, f8.2)', 'mean = ', mean
print '(a, f8.2)', 'max deviation = ', maxdev
print '(a, 5f7.1)', 'centered = ', t
end program field_stats_v0
$ gfortran -std=f2018 -Wall -O2 field_stats_v0.f90 -o v0 && ./v0
mean = 30.00
max deviation = 20.00
centered = -20.0 -10.0 0.0 10.0 20.0
It works. It is also a maintenance dead end: you cannot test the mean calculation in isolation, you cannot
reuse the centering logic on another array, the magic number 5 is repeated three times waiting to fall
out of sync with the array, and nothing documents which pieces of state each block reads and writes. Our
job is to fix all of that without changing that output.
Phase 1 — Read the Monolith and State What It Computes
Before touching anything, write down — in plain words — what the code does, block by block. This is the single most important refactoring habit: you cannot safely restructure what you cannot describe.
| Lines | What it computes | Reads | Writes |
|---|---|---|---|
| First loop | the arithmetic mean of t |
t |
mean |
| Second loop | the maximum deviation $\max_i \lvert t_i - \bar t\rvert$ | t, mean |
maxdev |
| Third loop | centers t in place (subtract the mean) |
t, mean |
t |
Two of the three blocks compute a single value from t (the mean, the max deviation). One block performs
an action on t (centering it in place). That distinction is not incidental — it is precisely the
function-versus-subroutine line from §6.1, and it tells us exactly what to extract.
Phase 2 — Find the Seams
Match each block to the procedure kind its data flow demands:
| Block | Procedure kind | Why | Intent of the array arg |
|---|---|---|---|
| mean | function mean_of(x) |
returns one value, no side effects | intent(in) |
| max deviation | function max_dev(x) |
returns one value, no side effects | intent(in) |
| centering | subroutine center(x) |
modifies the array in place, returns nothing | intent(inout) |
The two functions read t but must never change it, so their argument is intent(in) — and because they
have no side effects at all, they qualify as pure (§6.4). The subroutine both reads and overwrites its
array, so it is intent(inout). Notice we have also just eliminated the repeated 5: every extracted
procedure will take an assumed-shape array (§6.6) and ask it for its own size, so the length lives in one
place — the array declaration — and nowhere else.
Phase 3 — Extract the Procedures
Now rebuild the program as field_stats_v1, a thin driver over three named procedures:
program field_stats_v1
use, intrinsic :: iso_fortran_env, only: dp => real64
implicit none
real(dp) :: t(5) = [10.0_dp, 20.0_dp, 30.0_dp, 40.0_dp, 50.0_dp]
print '(a, f8.2)', 'mean = ', mean_of(t)
print '(a, f8.2)', 'max deviation = ', max_dev(t)
call center(t)
print '(a, 5f7.1)', 'centered = ', t
contains
pure function mean_of(x) result(m)
real(dp), intent(in) :: x(:) ! assumed-shape, read-only
real(dp) :: m
m = sum(x) / real(size(x), dp) ! size(x) replaces the magic 5
end function mean_of
pure function max_dev(x) result(d)
real(dp), intent(in) :: x(:)
real(dp) :: d
d = maxval(abs(x - mean_of(x))) ! whole-array; no loop
end function max_dev
subroutine center(x)
real(dp), intent(inout) :: x(:) ! read AND overwrite
x = x - mean_of(x)
end subroutine center
end program field_stats_v1
Three things collapsed at once. The hand-written summation loop became sum(x); the deviation loop became
one whole-array expression maxval(abs(x - mean_of(x))); and the centering loop became x = x - mean_of(x)
— all the array-thinking of Chapter 5, now living behind clean interfaces.
The functions even reuse each other: max_dev and center both call mean_of, so the mean is defined in
exactly one place.
Phase 4 — Prove You Changed Nothing
A refactor is only correct if behavior is preserved, and "I read it carefully" is not proof. The proof is a regression check: run both versions and compare the output byte for byte.
$ gfortran -std=f2018 -Wall -O2 field_stats_v1.f90 -o v1 && ./v1
mean = 30.00
max deviation = 20.00
centered = -20.0 -10.0 0.0 10.0 20.0
Identical to v0. Let us also confirm the numbers by hand, because that is the book's discipline: the mean
of $\{10,20,30,40,50\}$ is $150/5 = 30$; the deviations are $\{20,10,0,10,20\}$, so the maximum is $20$; and
centering subtracts $30$ to give $\{-20,-10,0,10,20\}$. The refactored code reproduces all three exactly.
That match is your license to delete v0.
The reasoning that matters: the regression check is not a formality — it is what separates refactoring from rewriting. When you modernize a 50,000-line legacy code in Part IV, you will not be able to reason about the whole thing at once; you will change one piece, confirm the output is unchanged, and repeat. This five-line program is that entire discipline in miniature.
Phase 5 — What the Refactor Bought
Count the gains, because each maps to a section of this chapter:
- Testability (§6.1, §6.4).
mean_ofandmax_devare now pure functions you can call from a test with a known input and check the answer — impossible when they were buried in loops. - Safety (§6.2). Every argument declares its
intent. The compiler now guaranteesmean_ofcannot accidentally modifyt, and it will reject any future edit that tries to. - Reuse and single-sourcing (§6.6). The mean is defined once and called three times; the array length is
read from
size(x), so the magic5is gone and the procedures work on an array of any length. - Readability. The main program is now a four-line story — mean, deviation, center, print — that reads like the description we wrote in Phase 1.
The kernel did not get faster (the compiler was already inlining these small loops), and that is the point: the refactor bought correctness insurance and future flexibility at no runtime cost. That trade is almost always worth making.
Discussion Questions
max_devcallsmean_of, which loops over the array;max_devthen loops again. For a five-element array this is irrelevant, but for a billion-element array you have made two passes where the monolith made one. Is the trade — clarity for a second pass — worth it? When would you fuse them back, and how would you decide (foreshadowing Chapter 28)?- We made
mean_ofandmax_devpurebut leftcenteras a plain subroutine. Couldcenterbepure? What would that require, and what does the answer tell you about the difference between "computes a value" and "performs an action"? - The regression check compared printed text. What could that miss that a comparison of the actual
real(dp)values would catch, and when does the distinction matter (preview of Chapter 20)?
Your Turn: Extensions
- Option A. Add a
pure function std_dev(x)returning the population standard deviation, and rebuild the driver to print it too. Confirm the mean and max-deviation output are unchanged (regression preserved). - Option B. The monolith hard-codes a five-element array. Change
tto a ten-element array in both versions. The refactored version should need edits in exactly one place; the monolith in three. Count the edits and explain the difference. - Option C. Introduce a deliberate bug into
v1— makemean_ofdivide bysize(x) - 1— and watch the regression check catch it. This is how a test suite protects you; write down what the mismatch looked like.
Key Takeaways
- The function-versus-subroutine decision falls straight out of data flow: blocks that compute one value become functions; blocks that act on state become subroutines. Describe the code first, and the procedure boundaries reveal themselves.
- Refactoring means restructuring without changing behavior, and the proof is a regression check against a reference output — the same discipline, at small scale, that makes legacy modernization safe.
- Extracting procedures with
intent,pure, and assumed-shape arguments buys testability, safety, reuse, and readability, often at zero runtime cost. That is one of the best trades in software.