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:

  1. Inseparability — values, name, and units are one object; you cannot pass the values without them.
  2. Safety — an object, once constructed, is always valid (its array is allocated, its strings are set).
  3. Self-description — the field can summarize itself (report) without the caller knowing its internals.
  4. 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_field concrete — no inheritance, no polymorphism. Everything here is derived types and type-bound procedures. In Chapter 10 you could make labeled_field one of a family (a scalar_field and a vector_field sharing 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

  1. All three components are allocatable. Trace exactly what happens, allocation by allocation, when a labeled_field local variable is returned from a function and then assigned to a caller's variable. Where are copies made, and where is storage freed?
  2. Why does the user-defined constructor take values as character(*)/real(dp), intent(in) arguments and assign them into the components, rather than requiring the caller to allocate and fill the components directly? Which design goal does this serve?
  3. report is a subroutine, but mean, min, max, range are pure functions. Justify each choice. What would break if you tried to make report a pure function?

Your Turn: Extensions

  • Option A. Add a standard_deviation type-bound function (population form, $\sqrt{\overline{x^2} - \overline{x}^2}$) and extend report to 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 (an intent(inout) method) that multiplies every value by factor, and prove to yourself that calling it on a copy b made with b = a leaves a untouched — the deep-copy guarantee in action.
  • Option C. Give labeled_field a 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 integer n versus 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 allocatable buys value semantics for free: deep-copy on assignment and automatic cleanup, with no copy routine or destructor to write.
  • A user-defined constructor (an interface named 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.