Case Study 2: Designing a Labeled-Field Container
"A number without its name and its units is not data. It is a rumor."
Executive Summary
Where the first case study refactored someone else's code, this one asks you to design a data structure
from a blank page — the harder and more valuable skill. Your simulation produces fields: temperature,
pressure, density, each a large array of numbers that means nothing without a name and a unit attached. You
will build a reusable labeled_field type that bundles the values with their metadata behind one name,
give it a user-defined constructor that guarantees a valid object, and attach type-bound diagnostics
(mean, min, max, range, report) so a field knows how to summarize itself. The design leans on
every idea in the chapter — components, nested-free simplicity, type-bound procedures with the class
passed object, a custom constructor, and (the crux) allocatable components for automatic deep-copy and
cleanup. The result is a component you could lift straight into the heat solver's diagnostics.
Skills applied: designing a type around an invariant (§9.5); allocatable components and deferred-length
character components (§9.1, §9.4); a user-defined constructor via an interface named like the type (§9.4);
type-bound procedures with the correct class(...) passed object (§9.2); whole-array intrinsics on a
component (sum, minval, maxval from Chapter 5).
Background
Scientific output has a recurring failure mode: a bare array of numbers escapes the routine that produced
it, and downstream code — or a human reading a log — no longer knows what the numbers are. Is 12.5 a
temperature in kelvin, a pressure in pascals, a mistake? The fix is to never let the values travel without
their identity. We want a type whose invariant is exactly "these numbers, and what they mean," so that it
is impossible to hold the data without also holding its name and units.
Design goals, in priority order:
- Inseparability — values, name, and units are one object; you cannot pass the values without them.
- Safety — an object, once constructed, is always valid (its array is allocated, its strings are set).
- Self-description — the field can summarize itself (
report) without the caller knowing its internals. - Value semantics — copying a field yields an independent copy; no accidental aliasing; no leaks.
Phase 1 — Choose the Components
The invariant dictates the components. We need the data (an array whose size is unknown until run time → an allocatable component) and its metadata (two strings of unknown length → deferred-length allocatable character components, Chapter 12):
type :: labeled_field
character(:), allocatable :: name ! e.g. "temperature"
character(:), allocatable :: units ! e.g. "K"
real(dp), allocatable :: values(:) ! the data, sized at run time
end type labeled_field
Three allocatable components, and that choice alone delivers goal 4 for free: because all three are
allocatable, assignment b = a deep-copies every one of them, and all three are deallocated automatically
when a labeled_field goes out of scope. We did not write a copy routine or a destructor; the type
behaves like a value because of what its components are. That is the §9.4 lesson cashed in.
Phase 2 — Guarantee Validity with a Constructor
The default structure constructor would let a caller build a half-formed field (name set, values
unallocated), which violates goal 2. We close that door by supplying our own constructor — an interface
with the type's name — that requires the data and metadata together and allocates everything:
interface labeled_field
module procedure new_labeled_field
end interface labeled_field
function new_labeled_field(name, units, values) result(f)
character(*), intent(in) :: name, units
real(dp), intent(in) :: values(:)
type(labeled_field) :: f
f%name = name ! deferred-length assignment: allocates to fit
f%units = units
f%values = values ! allocatable-array assignment: sizes itself to source
end function new_labeled_field
Now labeled_field("temperature", "K", data) is the only convenient way to build one, and it cannot produce
an invalid object. Note we never call allocate explicitly: assigning to a deferred-length or allocatable
component is an allocation. (The built-in structure constructor still technically exists, but our
three-argument form is what callers reach for, and the compiler tells the two apart by their argument
types.)
Phase 3 — Attach the Diagnostics as Type-Bound Procedures
A field should know how to summarize itself. We bind five procedures, each taking the object as a
class(labeled_field) passed argument (the non-negotiable §9.2 detail), four of them pure reductions over
the values, and one report that composes them into a labeled block of output:
type :: labeled_field
character(:), allocatable :: name
character(:), allocatable :: units
real(dp), allocatable :: values(:)
contains
procedure :: mean => lf_mean
procedure :: minimum => lf_min
procedure :: maximum => lf_max
procedure :: range => lf_range
procedure :: report => lf_report
end type labeled_field
The reductions are one-liners over the allocatable component, using the whole-array intrinsics from Chapter 5:
pure function lf_mean(self) result(m)
class(labeled_field), intent(in) :: self
real(dp) :: m
m = sum(self%values) / real(size(self%values), dp)
end function lf_mean
! lf_min: m = minval(self%values)
! lf_max: m = maxval(self%values)
! lf_range: r = maxval(self%values) - minval(self%values)
and report lets the field describe itself with its name and units attached — the whole point of the type:
subroutine lf_report(self)
class(labeled_field), intent(in) :: self
print '(4a)', 'field : ', self%name, ' in ', self%units
print '(a, i0)', ' n = ', size(self%values)
print '(a, f8.3)', ' mean = ', self%mean()
print '(a, f8.3)', ' min = ', self%minimum()
print '(a, f8.3)', ' max = ', self%maximum()
print '(a, f8.3)', ' range= ', self%range()
end subroutine lf_report
Phase 4 — Assemble and Verify
The full module and a driver are in the chapter's code/ directory pattern; here is the driver and its
hand-checked output:
program cs2_demo
use diagnostics, only: labeled_field
use, intrinsic :: iso_fortran_env, only: dp => real64
implicit none
type(labeled_field) :: temp
temp = labeled_field("temperature", "K", [1.0_dp, 2.0_dp, 3.0_dp, 4.0_dp, 5.0_dp])
call temp%report()
end program cs2_demo
$ gfortran -std=f2018 -Wall -O2 diagnostics.f90 -o diag && ./diag
field : temperature in K
n = 5
mean = 3.000
min = 1.000
max = 5.000
range= 4.000
Sanity check by hand. For values $[1,2,3,4,5]$: $n = 5$; mean $= 15/5 = 3.0$; min $= 1.0$; max $= 5.0$;
range $= 5.0 - 1.0 = 4.0$. Every line matches, and — crucially — the output names itself: "temperature in
K," not an anonymous 3.000. The four design goals are met: the data cannot travel without its identity
(one object), a constructed field is always valid (the constructor allocates everything), the field
summarizes itself (report), and copying is safe (allocatable components deep-copy).
Phase 5 — Fold It Into the Solver
The payoff is that labeled_field is not a toy — it is a diagnostics layer for the heat solver. Recall the
project's field_t holds the 2D temperature array u(:,:). A diagnostics routine can flatten the interior
into a labeled_field and let it report:
! sketch: summarize the current temperature field each output step
temp_summary = labeled_field("temperature", "K", reshape(f%u, [size(f%u)]))
call temp_summary%report()
Now every output step logs a self-describing summary — mean, min, max, range, all labeled — and the same
labeled_field type serves any scalar field the simulation later adds (a pressure, a source term, an error
norm). One well-designed type, reused everywhere, each instance knowing its own name. That reuse is the
dividend of designing around the invariant instead of around one immediate need.
A note on scope. We deliberately kept
labeled_fieldconcrete — no inheritance, no polymorphism. Everything here is derived types and type-bound procedures. In Chapter 10 you could makelabeled_fieldone of a family (ascalar_fieldand avector_fieldsharing an abstract parent), but resist that until you have two real cases that need it. A concrete type you can read beats an abstract hierarchy you must decode — the same clarity-first instinct that drove the whole design.
Discussion Questions
- All three components are
allocatable. Trace exactly what happens, allocation by allocation, when alabeled_fieldlocal variable is returned from a function and then assigned to a caller's variable. Where are copies made, and where is storage freed? - Why does the user-defined constructor take
valuesascharacter(*)/real(dp), intent(in)arguments and assign them into the components, rather than requiring the caller toallocateand fill the components directly? Which design goal does this serve? reportis asubroutine, butmean,min,max,rangearepure functions. Justify each choice. What would break if you tried to makereportapure function?
Your Turn: Extensions
- Option A. Add a
standard_deviationtype-bound function (population form, $\sqrt{\overline{x^2} - \overline{x}^2}$) and extendreportto print it. Verify by hand on $[2,4,4,4,5,5,7,9]$ (you should get mean $5$, variance $4$, standard deviation $2$). - Option B. Add a
rescale(self, factor)subroutine (anintent(inout)method) that multiplies every value byfactor, and prove to yourself that calling it on a copybmade withb = aleavesauntouched — the deep-copy guarantee in action. - Option C. Give
labeled_fielda second constructor that builds a zeroed field of a given size and name (labeled_field("temperature", "K", n=100)), and explain how the compiler disambiguates it from the values-supplying constructor. (Hint: the argument that distinguishes them is the integernversus the real array.)
Key Takeaways
- Design a type around its invariant — "these numbers, and what they mean" — and the component choices, the constructor, and the methods all follow from it.
- Making every component
allocatablebuys value semantics for free: deep-copy on assignment and automatic cleanup, with no copy routine or destructor to write. - A user-defined constructor (an
interfacenamed like the type) is where you guarantee validity — it is the one place that can ensure every allocatable component is allocated before the object escapes. - Type-bound diagnostics let data describe itself; a field that knows its own name and units turns anonymous numbers into trustworthy, self-labeling output — and the type, designed once, is reused across every field the simulation will ever have.