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_of and max_dev are 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 guarantees mean_of cannot accidentally modify t, 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 magic 5 is 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

  1. max_dev calls mean_of, which loops over the array; max_dev then 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)?
  2. We made mean_of and max_dev pure but left center as a plain subroutine. Could center be pure? What would that require, and what does the answer tell you about the difference between "computes a value" and "performs an action"?
  3. 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 t to 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 — make mean_of divide by size(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.