32 min read

> *"All problems in computer science can be solved by another level of indirection — except for the

Prerequisites

  • 8
  • 9

Learning Objectives

  • Extend an existing derived type with `extends`, inheriting its components and type-bound procedures and overriding what you choose.
  • Distinguish `type` (fixed, known at compile time) from `class` (polymorphic, resolved at run time), and explain what polymorphism buys you.
  • Recover a polymorphic object's concrete type at run time with `select type`, and know when needing it is a design smell.
  • Define an abstract type with `deferred` bindings to specify an interface that extensions must implement, and write a `final` procedure to clean up a type's resources.
  • Judge honestly when object orientation helps scientific code and when its overhead and indirection hurt — and keep dynamic dispatch out of hot inner loops.
  • Assemble a small framework: an abstract `solver_t` behind which a `heat_solver_t` is one interchangeable implementation of several.

Chapter 10: Object-Oriented Fortran — Classes, Inheritance, and Polymorphism

"All problems in computer science can be solved by another level of indirection — except for the problem of too many levels of indirection." — David Wheeler

Overview

Fortran has objects. It has inheritance, run-time polymorphism, abstract interfaces, and destructors — the whole vocabulary you may have met in C++, Java, or Python. The 2003 standard added them, and they have been in every compiler you are likely to use for the better part of two decades. If your mental image of Fortran stops at COMMON blocks, this is the chapter that should retire it for good: the language you are learning can express a plugin architecture, a family of interchangeable solvers, or a hierarchy of physical models as cleanly as any modern language.

But object orientation in a numerical language is a double-edged tool, and this chapter is as much about judgment as about syntax. The same features that let you swap a heat solver for a wave solver behind a single interface can, used carelessly, drop a dynamic function call into the middle of a billion-iteration loop and quietly halve your performance. A computational scientist needs both halves of this: the ability to build the abstraction and the discipline to keep it away from the hot path. So we will teach the mechanics precisely — because OOP syntax is easy to get subtly wrong — and then we will be honest, at length, about when to reach for it and when to leave it in the drawer.

This chapter builds directly on Chapter 9. There you learned to define a type, give it components, and bind procedures to it. Here we let types relate to one another: one type extends another, a variable declared as one type holds a value of another, and a call resolves to different code depending on what it is actually handed. Make sure you are comfortable with derived types and type-bound procedures before you go on; everything below assumes them.

In this chapter, you will learn to:

  • Build an inheritance hierarchy with extends, and call a parent's method from a child that overrides it.
  • Declare polymorphic variables with class, and understand exactly how they differ from type.
  • Dispatch a call to the right implementation at run time, and unwrap a polymorphic value with select type.
  • Specify an interface with an abstract type and deferred bindings, so the compiler forces every implementation to conform.
  • Write a final procedure — Fortran's destructor — and understand why allocatable components mean you rarely need one.
  • Weigh the costs (dispatch overhead, indirection, complexity) against the benefits (abstraction, extensibility), and design a solver framework that puts the polymorphism in the right place.

Learning Paths

How to read this chapter by track. - 🔬 Scientist ("I want cleaner, extensible models") — read §10.1, §10.2, and especially §10.5 and §10.6; skim §10.3–§10.4. The framework in §10.6 is the payoff you are here for. - 📖 Standard — read straight through; this is a core language chapter and every section carries a rule you will want. - 🔧 Legacy ("I maintain old code") — you will rarely find OOP in FORTRAN 77, but you may be asked to add it when modernizing. Read §10.1–§10.2 and §10.5; the honesty of §10.5 is your ammunition against over-engineering a migration. - ⚡ HPC ("I need it fast") — §10.5 is mandatory: it is where we explain why class must never appear in your innermost loop, and how to get abstraction without paying for it per element. Skim the rest.

This chapter is marked advanced, and it earns the label. The Scientist and HPC tracks can treat the mechanics (§10.3–§10.4) lightly on a first pass and return when they need them; no one should skip §10.5.


10.1 Type Extension: Inheritance with extends

Start with a concrete problem. In Chapter 9 you built derived types to bundle related data — a particle, a grid cell, a labeled field. Suppose now you have several kinds of thing that share some data and behavior but differ in the rest: several shapes that all have a label but compute their area differently; several equations of state that all take a temperature but return pressure by different formulas; several solvers that all hold a timestep but advance the field by different schemes. You could copy the shared parts into every type by hand. Type extension lets you say instead: this new type is like that old one, plus more.

Definition (type extension). Defining a new derived type that inherits all the components and type-bound procedures of an existing type, then adds its own and/or overrides inherited procedures. The existing type is the parent (or base type); the new one is the extension (or child). You write it with the extends attribute: type, extends(parent_t) :: child_t.

Here is the mechanism in its smallest honest form. A base shape_t carries a text label and knows how to describe itself; a circle_t extends it, adding a radius and the ability to compute an area:

module shapes_basic
  use, intrinsic :: iso_fortran_env, only: dp => real64
  implicit none
  private
  public :: shape_t, circle_t

  type :: shape_t
    character(len=20) :: label = "shape"
  contains
    procedure :: describe => shape_describe
  end type shape_t

  type, extends(shape_t) :: circle_t
    real(dp) :: radius = 0.0_dp
  contains
    procedure :: describe => circle_describe   ! override the inherited one
    procedure :: area     => circle_area       ! a brand-new binding
  end type circle_t

contains

  subroutine shape_describe(self)
    class(shape_t), intent(in) :: self
    print '(a)', 'a shape labelled '//trim(self%label)
  end subroutine shape_describe

  subroutine circle_describe(self)
    class(circle_t), intent(in) :: self
    call self%shape_t%describe()               ! call the PARENT'S version...
    print '(a, f6.3)', '  radius = ', self%radius   ! ...then add our own line
  end subroutine circle_describe

  function circle_area(self) result(a)
    class(circle_t), intent(in) :: self
    real(dp) :: a
    real(dp), parameter :: pi = 3.141592653589793_dp
    a = pi * self%radius**2
  end function circle_area

end module shapes_basic

Three things in that listing are the whole of inheritance, and each deserves a moment.

A circle_t is a shape_t. It automatically has a label component — you did not declare one — and it automatically has a describe binding. Extension is additive: the child starts with everything the parent had. In the driver below, c%label reads the inherited component as if circle_t had declared it itself.

The child can add components and bindings. radius and area exist only on circle_t. A plain shape_t has no radius and no area procedure; asking for them would be a compile-time error.

The child can override an inherited binding. Both types have a describe binding, but circle_t maps it to circle_describe. When you call describe on a circle, you get the circle's version. And a child that overrides can still reach the parent's version explicitly through the parent component — every extension contains its parent as a component named after the parent type, so self%shape_t%describe() invokes shape_describe non-polymorphically. This "call super then extend" pattern is one you will use constantly.

A driver makes the behavior concrete:

program ex01
  use shapes_basic, only: circle_t
  implicit none
  type(circle_t) :: c

  c%label  = "disk"        ! inherited from shape_t
  c%radius = 2.0_dp        ! added by circle_t
  call c%describe()        ! resolves to circle_describe
  print '(a, f9.5)', 'area = ', c%area()
end program ex01
$ gfortran -std=f2018 -Wall example-01-inheritance.f90 -o ex01 && ./ex01
a shape labelled disk
  radius =  2.000
area =  12.56637

The area is $\pi r^2 = \pi \cdot 2^2 = 4\pi \approx 12.56637$, computed by hand — no program in this book is run to produce its output. The two describe lines show the override in action: circle_describe first delegated to shape_describe (the "a shape labelled disk" line) and then printed its own radius line.

💡 Intuition: Think of extension as layering, not copying. A circle_t value is physically a shape_t with extra fields bolted on the end. That is why the parent part has a name (%shape_t) you can reach, and why a circle can be used anywhere a shape is expected — the shape it "is" sits right there inside it. Inheritance in Fortran is composition the compiler manages for you.

📜 From History: All of this arrived in Fortran 2003, the standard that made Fortran object-oriented. Before 2003 you emulated inheritance by hand — a child type with the parent type as an explicit first component, and a lot of child%parent%... typing. The 2003 committee blessed exactly that pattern with syntax and gave the compiler enough information to make it type-safe and to dispatch calls at run time. When you write extends, you are using a feature that is now old enough to vote.

🔄 Check Your Understanding 1. A circle_t extends shape_t. Does a circle_t value have a label component? Where did it come from? 2. Inside circle_describe, what does self%shape_t%describe() call, and why would you write it? 3. Can you call %area() on a variable declared type(shape_t)? Why or why not?

Answers (1) Yes — label is inherited from shape_t; extension is additive, so the child has every component of the parent without redeclaring it. (2) It calls the parent's describe (shape_describe) non-polymorphically, via the parent component %shape_t; you write it to reuse the base behavior and then add to it ("call super, then extend"). (3) No — area is a binding of circle_t, not of shape_t. A shape_t has no area procedure, so the reference would not compile.


10.2 class vs type: Polymorphism

So far every variable has been declared with type(...), and its type has been fixed and known to the compiler. That is exactly what you want most of the time, and — as §10.5 will hammer — it is what you want all of the time in a hot loop. But sometimes you want a variable that can hold any type in a family: a handle that might point at a circle_t today and a rectangle_t tomorrow, with the code that uses it not caring which. That is what class is for.

Definition (class). A variable, component, or dummy argument declared class(base_t) is polymorphic: at run time it may hold a value of base_t or of any type that extends it. Its declared type (base_t, fixed, what the compiler sees) may differ from its dynamic type (the actual type it holds right now, which can change). Contrast type(base_t), which is monomorphic — it is always exactly base_t, declared and dynamic type identical, decided at compile time.

Definition (polymorphism). From the Greek for "many shapes": the ability of one piece of code to operate on values of different types through a common interface, with the specific behavior chosen by the value's dynamic type. When you call s%area() on a polymorphic s, the call dispatches to the area procedure of whatever s currently is — circle or rectangle — without the calling code naming the type. This run-time selection is the heart of object-oriented programming.

A polymorphic variable comes with one firm restriction worth stating up front, because it trips everyone:

⚠️ Common Pitfall: A class(...) variable that is a plain local — not a dummy argument, not allocatable, not a pointer — is not allowed. Polymorphism needs a level of indirection to work, so a polymorphic non-dummy object must be allocatable or pointer. This is fine: fortran class(shape_t), allocatable :: s ! OK: allocatable polymorphic and this will not compile: fortran class(shape_t) :: s ! ERROR: local class must be allocatable/pointer/dummy Dummy arguments are the common exception: subroutine f(self); class(shape_t), intent(in) :: self needs no allocatable — the caller's storage supplies the indirection. That is why every type-bound procedure's passed object is class, not type: it must accept the type and every extension of it.

Here is polymorphism doing real work. We give shape_t an area of its own (returning zero — a placeholder we will be embarrassed by and fix in §10.4), let circle_t and rectangle_t override it, then compute a total area through a single polymorphic handle that does not know or care which shape it holds:

type(shape_box_t) :: coll(2)                 ! a heterogeneous collection (see below)
real(dp) :: total
integer  :: i

allocate(coll(1)%obj, source = circle_t(radius = 2.0_dp))
allocate(coll(2)%obj, source = rectangle_t(width = 3.0_dp, height = 4.0_dp))

total = 0.0_dp
do i = 1, size(coll)
  total = total + coll(i)%obj%area()         ! dispatches per element
end do

The line coll(i)%obj%area() is the payoff. On the first pass coll(1)%obj is a circle, so it calls circle_area; on the second it is a rectangle, so it calls rectangle_area. The loop names neither type. Add a triangle_t tomorrow and this loop does not change a character — that extensibility is the reason object orientation exists.

Two details in that snippet are load-bearing. First, allocate(..., source = circle_t(radius=2.0_dp)) allocates the polymorphic component and sets its dynamic type and value from a constructor. (Note the keyword radius= in the constructor: because circle_t extends shape_t, its structure constructor lists the inherited label first, so positional circle_t(2.0_dp) would try to put 2.0_dp into label — a type error. Name the component and you are safe.) Second, and more subtly:

🚪 Threshold Concept — a polymorphic array holds one dynamic type, so heterogeneous collections need a wrapper. You might reach for class(shape_t), allocatable :: coll(:) to hold a mix of shapes. It will not do what you want: every element of a polymorphic array must have the same dynamic type. To store a genuinely mixed bag — a circle here, a rectangle there — you wrap the polymorphic value in a tiny derived type and make an array of that: fortran type :: shape_box_t class(shape_t), allocatable :: obj end type type(shape_box_t) :: coll(2) ! each box's obj can be a different shape The box is monomorphic (all boxes are shape_box_t); the polymorphism lives one level down, in each box's obj. This "array of boxes" idiom is how every heterogeneous container in Fortran is built. Once you see that a polymorphic array is uniform but an array of boxes is not, a whole category of confusion evaporates.

🐍 Python Comparison: In Python everything is polymorphic — a list can hold a circle and a rectangle and a string, and x.area() looks the type up at run time, every time, by dictionary. That flexibility is free to write and expensive to run: the lookup happens on every call and cannot be optimized away. Fortran inverts the default. Monomorphic type is the norm — no lookup, the compiler inlines the call — and you opt in to run-time dispatch with class only where you need it. The result is that Fortran's polymorphism costs you nothing until you use it, and even then you decide where. Python makes the convenient thing automatic; Fortran makes the fast thing automatic.

🔄 Check Your Understanding 1. What is the difference between a variable's declared type and its dynamic type? 2. Why can't you write class(shape_t) :: s as a local variable, and what two attributes fix it? 3. Why does class(shape_t), allocatable :: coll(:) fail to hold a mix of circles and rectangles, and what is the standard fix?

Answers (1) The declared type is fixed and known to the compiler (shape_t); the dynamic type is the actual type the variable holds at run time (circle_t, say) and can vary. For a type(...) variable they are always the same; for class(...) they may differ. (2) A polymorphic object needs indirection to hold different types; a plain local has none. Declare it allocatable or pointer (or make it a dummy argument). (3) Every element of a polymorphic array must share one dynamic type. Wrap the polymorphic value in a small derived type (type :: shape_box_t; class(shape_t), allocatable :: obj; end type) and make an array of those boxes.


10.3 select type: Recovering the Concrete Type

Most of the time, polymorphism means you never ask what type you are holding — you call area() and trust dispatch to do the right thing. That is the goal, and code that achieves it is clean. But occasionally you must do something type-specific that no common interface covers: print a circle's radius, or take a shortcut that only applies to one implementation. For that, Fortran gives you select type.

Definition (select type). A construct that inspects the dynamic type of a polymorphic object and runs a different block depending on what it is. A type is (t) guard matches when the dynamic type is exactly t; a class is (t) guard matches when the dynamic type is t or any extension of it; a class default guard catches anything unmatched. Inside a matched block, the object is treated with that more specific type, so you may use components and bindings the base type lacks.

Continuing the shapes, suppose we want to print each shape's defining measurement — a radius for circles, width-by-height for rectangles — which the common area interface cannot express:

do i = 1, size(coll)
  select type (o => coll(i)%obj)          ! o is an alias with the matched type
  type is (circle_t)
    print '(a, f6.3)', 'circle, radius = ', o%radius
  type is (rectangle_t)
    print '(a, f6.3, a, f6.3)', 'rectangle, ', o%width, ' x ', o%height
  class default
    print '(a)', 'some other shape'
  end select
end do
$ gfortran -std=f2018 -Wall example-02-polymorphism.f90 -o ex02 && ./ex02
circle, radius =  2.000
rectangle,  3.000 x  4.000
total area =   24.56637

Inside the type is (circle_t) block, the alias o has type circle_t, so o%radius is legal there and nowhere else. The total, $4\pi + 12 \approx 24.56637$, is the sum of the circle's area and the rectangle's, computed by hand.

The o => form gives the matched object a temporary name; you can also write select type (coll(i)%obj) and refer to the object by its original designator. The alias form is cleaner and is what you will usually see.

Now the important judgment, which separates a Fortran programmer from a Fortran engineer:

⚠️ Common Pitfall — select type is often a design smell. Every select type is a place where you stopped trusting polymorphism and started asking "what are you, really?" A long chain of type is (a) ... type is (b) ... type is (c) that does the actual work is inheritance turned inside out: add a new type and you must hunt down and edit every such chain, which is precisely the maintenance burden that type-bound procedures exist to prevent. Before writing one, ask: could this be a deferred binding (§10.4) instead, so each type carries its own behavior and dispatch handles the selection? Legitimate uses do exist — recovering a value's type at a boundary (say, after reading it polymorphically), or a genuine one-off — but if you find yourself writing the same select type in three places, you have found three places that should have been a method.

🐛 Find the Bug. A reader writes this to handle a square_t that extends rectangle_t: fortran select type (o => coll(i)%obj) class is (rectangle_t) print *, 'a rectangle of area ', o%width * o%height type is (square_t) print *, 'a square' ! never reached? end select They complain the type is (square_t) block never runs for a square. What is happening?

Answer Nothing is wrong with the ordering — the standard says the most specific matching guard wins regardless of source order, so a square_t would in fact select type is (square_t) over class is (rectangle_t). The real trap is the opposite worry: mixing class is and type is makes the selection rules subtle, and a reader can no longer tell at a glance which block fires. The fix is clarity, not a reorder: prefer a type is guard for each concrete type you actually handle, and reserve class is/class default for the genuine "everything else" case. If you need per-type behavior for square and rectangle, that behavior wants to be a deferred binding, not a select type.

🔄 Check Your Understanding 1. What is the difference between type is (circle_t) and class is (circle_t) in a select type? 2. Why is a select type chain that implements behavior often a sign you should have used a deferred binding?

Answers (1) type is (circle_t) matches only when the dynamic type is exactly circle_t; class is (circle_t) matches circle_t and any type that extends it. (2) Because adding a new type then forces you to edit every such chain by hand, whereas a deferred binding lets each type carry its own behavior and lets dispatch pick the right one automatically — no chain to maintain.


10.4 Abstract Types, deferred Bindings, and final Procedures

In §10.2 we gave shape_t an area that returned zero. That was a lie of convenience: there is no such thing as "the area of a generic shape," and returning zero invites a bug where someone forgets to override it and silently gets nonsense. What we actually meant is: every shape must supply an area, but the base shape cannot say what it is. Fortran lets you say exactly that.

Definition (abstract type). A derived type declared type, abstract :: t that cannot be instantiated — you may not declare a type(t) variable or allocate one — and exists only to be extended. It defines the common components and the interface (via deferred bindings) that its extensions must fulfill. A polymorphic class(t) variable is still fine; it just always holds some concrete extension.

Definition (deferred binding). A type-bound procedure declared deferred in an abstract type: it names a binding and its interface but supplies no implementation. Every concrete (non-abstract) extension must provide one, or the compiler rejects it. A deferred binding is a contract — "you are not a valid shape until you can compute your area" — enforced at compile time.

Rewriting the hierarchy honestly, shape_t becomes abstract and area becomes a promise:

module shapes_abstract
  use, intrinsic :: iso_fortran_env, only: dp => real64
  implicit none
  private
  public :: shape_t, circle_t, rectangle_t

  type, abstract :: shape_t
    character(len=20) :: label = "shape"
  contains
    procedure(area_i), deferred :: area          ! no body — every child supplies it
  end type shape_t

  abstract interface
    function area_i(self) result(a)
      import :: shape_t, dp                       ! bring host names into the interface
      class(shape_t), intent(in) :: self
      real(dp) :: a
    end function area_i
  end interface

  type, extends(shape_t) :: circle_t
    real(dp) :: radius = 0.0_dp
  contains
    procedure :: area => circle_area
  end type circle_t

  type, extends(shape_t) :: rectangle_t
    real(dp) :: width = 0.0_dp, height = 0.0_dp
  contains
    procedure :: area => rectangle_area
  end type rectangle_t

contains
  function circle_area(self) result(a)
    class(circle_t), intent(in) :: self
    real(dp) :: a
    a = 3.141592653589793_dp * self%radius**2
  end function circle_area

  function rectangle_area(self) result(a)
    class(rectangle_t), intent(in) :: self
    real(dp) :: a
    a = self%width * self%height
  end function rectangle_area
end module shapes_abstract

Two syntactic details are the ones people get wrong, so look closely. The deferred binding refers to an abstract interface — a named interface block that specifies the procedure's arguments and result but no body. Inside that interface body, the import statement is required: the interface is its own scoping unit, so it does not automatically see the shape_t type or the dp kind from the surrounding module, and import :: shape_t, dp pulls them in. Forget the import and you get a baffling "type not defined" error on a type that is plainly right there.

The contract now has teeth. Try to declare type(shape_t) :: s and the compiler refuses — abstract types cannot be instantiated. Try to write a triangle_t that extends shape_t but forgets to provide area, and the compiler refuses that too, unless you mark triangle_t itself abstract. The zero-returning placeholder is gone, and with it the whole class of "forgot to override" bugs.

$ gfortran -std=f2018 -Wall example-03-abstract-final.f90 -o ex03 && ./ex03
circle area    =  12.56637
rectangle area =  12.00000
allocated managed buffer A
finalizing A
after deallocate

final procedures — Fortran's destructor

The last piece of the object-oriented toolkit is cleanup. When a value is destroyed — deallocated, or when a local goes out of scope at the end of a procedure — you sometimes need to run code first: close a file it opened, release a C resource it holds, log that it happened. That code is a final procedure.

Definition (final). A subroutine listed in a type's contains section under the final :: keyword, called automatically ("finalized") just before an object of that type is destroyed — by deallocate, by going out of scope, or before it is overwritten in an intrinsic assignment. It is Fortran's destructor. Critically, a final subroutine takes its argument as type(...), not class(...) (finalization is not a dispatched call), and a type may have several finalizers for different ranks.

type :: managed_t
  character(len=8)      :: name = ""
  real(dp), allocatable :: data(:)
contains
  final :: managed_free
end type managed_t
...
subroutine managed_free(self)
  type(managed_t), intent(inout) :: self       ! NOTE: type, not class
  print '(a)', 'finalizing '//trim(self%name)
end subroutine managed_free

In the run above, deallocate(m) on a managed_t fires managed_free, which prints finalizing A before the storage is released — that is why finalizing A appears between the "allocated" and "after deallocate" lines. The ordering is deterministic here because we deallocate explicitly at a known point.

Now the honest caveat, which is also a design lesson:

⚠️ Common Pitfall — you need final far less than you think, and gfortran's support has historically had gaps. Notice that managed_t's data(:) is allocatable. When a managed_t is destroyed, Fortran automatically deallocates its allocatable components — you do not need a finalizer to free data. This is the great advantage of allocatable components over pointer components (Chapter 9) and over pointers (Chapter 11): they clean up after themselves. A final procedure earns its keep only for resources Fortran does not manage — an open file unit, a C malloc, a lock. And a warning for the careful: gfortran's finalization was incomplete for years and, even in recent versions, has known gaps for edge cases (finalizing function results, some assignment cases). For the common cases — explicit deallocate, scope exit of a simple object — it works; rely on it there, and do not build critical logic on a finalizer firing in an exotic situation. (Flagged as a version-sensitive feature.)

🔄 Check Your Understanding 1. Why can't you declare a variable type(shape_t) once shape_t is abstract? What can you declare? 2. What forces circle_t to provide an area procedure, and when is that enforced? 3. Why does a managed_t with an allocatable component rarely need a final procedure to free it?

Answers (1) An abstract type cannot be instantiated, so type(shape_t) is illegal; you may declare class(shape_t), allocatable (or pointer), which always holds a concrete extension. (2) The deferred binding in the abstract parent is a compile-time contract: any concrete extension must implement it or the program does not compile. (3) Because Fortran automatically deallocates allocatable components when the object is destroyed — the component frees itself, no finalizer required.


10.5 When OOP Helps — and When It Hurts

Everything above is genuinely useful and genuinely dangerous, and a computational scientist has to hold both truths at once. Object orientation is a tool for managing variation and change. It shines exactly where you have several interchangeable implementations of one idea, and it costs you exactly where you can least afford it — in the inner loop. Here is the honest accounting.

Where OOP helps.

  • Abstraction and interchangeability. When you have a family of things that answer the same question differently — several time integrators, several equations of state, several boundary conditions, several linear solvers — an abstract type with a deferred binding lets the rest of your code talk to the family through one interface and stay ignorant of which member it holds. Swapping Euler for Runge–Kutta (Chapter 23) becomes a one-line change at the point of choice, not a rewrite of the caller.
  • Extensibility without editing. Add a new implementation and the existing code that uses the interface does not change — the opposite of a select type chain. This is the property that lets a large scientific framework accept a new physics module as a plugin.
  • Bundling state with behavior. A type that carries its own parameters and knows how to advance itself is easier to reason about than a bag of free-floating arrays and a subroutine that hopes it was passed the right ones. Modern Fortran codes (Chapter 36) use this to keep large simulations navigable.
  • Separating interface from implementation. Combined with submodules (Chapter 8), abstract types let you fix an interface and vary the implementation independently — valuable when many people build on the same framework.

Where OOP hurts.

  • Dynamic dispatch is not free. A call through a class variable cannot, in general, be resolved at compile time — the compiler emits an indirect call through a table, chosen at run time from the object's dynamic type. That indirect call cannot be inlined, which is often the more expensive loss: inlining is what lets the optimizer fuse a small routine into its caller and vectorize the result. A dispatched call in a per-element kernel therefore blocks the very optimizations Fortran exists to enable.
  • Polymorphic data means indirection and cache misses. A class(...) object carries a hidden descriptor; allocatable polymorphic components live at the end of a pointer. Chasing that indirection for every element of a large array scatters your memory access and defeats the streaming, contiguous access pattern that Chapter 5 taught you to prize and Chapter 27 will measure.
  • Complexity is a real cost. A deep hierarchy can be harder to follow than the three subroutines it replaced. Object orientation pays off when there is genuine variation to manage; imposed on code that has none, it is pure overhead in both cycles and comprehension.

⚡ Performance Note — keep class out of the hot loop. The single most important performance rule of this chapter: put polymorphism at the coarse grain, never the fine grain. It is entirely fine to choose a class(solver_t) once, before the time loop, and dispatch to its step once per timestep — that is one indirect call amortized over millions of arithmetic operations, utterly negligible. It is a catastrophe to make each cell of your grid a polymorphic object and dispatch a method per cell: now you pay an un-inlinable indirect call a billion times, and you have thrown away vectorization on top. The arithmetic inside the kernel should be plain type, plain arrays, monomorphic and inlinable. Abstract the solver; never abstract the cell. A dispatched call can cost on the order of tens of cycles and, worse, blocks inlining and vectorization — a Tier-2 order-of-magnitude figure, but the direction is not in doubt. We return to exactly why in Chapter 27.

🔗 Connection: This is not a Fortran quirk; it is universal. C++ virtual calls, Java interface dispatch, and Python method lookup all pay the same tax, and every high-performance code in every language keeps dynamic dispatch out of its kernels for the same reason. What is distinctive about Fortran is that its default — monomorphic type, contiguous arrays, no aliasing — is the fast one, so you only pay when you deliberately opt in. Use that. Design your abstractions so the polymorphism sits at the level of "which solver," "which model," "which output format" — decisions made a handful of times — and let the numbers underneath be as dumb, flat, and fast as possible.

The rule of thumb, then: reach for OOP when you have real, plural variation to manage and the dispatch happens rarely; leave it alone when you have one implementation, or when the dispatch would land in a loop. Most scientific codes end up with a thin layer of object orientation at the top — choosing components — over a large body of flat, monomorphic numerical kernels. That shape is not a compromise; it is the correct design.


10.6 A Worked Framework: An Abstract solver_t

Now we put every piece together on the running project, in the way §10.5 recommends: polymorphism at the coarse grain. We want the heat solver to be one of several possible solvers behind a single interface, so that a driver can hold a class(solver_t) and advance the simulation without knowing whether it is heat, wave, or diffusion underneath. The dispatch happens once per timestep — coarse-grained, cheap — while the per-cell arithmetic inside each solver stays flat and fast.

The design is exactly the abstract-type pattern from §10.4, applied to solving instead of shapes:

module solver_base
  use kinds,      only: dp
  use heat_types, only: field_t                 ! the field_t from Chapter 9
  implicit none
  private
  public :: solver_t

  type, abstract :: solver_t
    real(dp) :: alpha = 0.0_dp                   ! a physical parameter
    real(dp) :: dt    = 0.0_dp                   ! the timestep
  contains
    procedure(step_i), deferred :: step          ! "advance the field one step"
  end type solver_t

  abstract interface
    subroutine step_i(self, fld)
      import :: solver_t, field_t
      class(solver_t), intent(in)    :: self
      type(field_t),   intent(inout) :: fld
    end subroutine step_i
  end interface
end module solver_base

solver_t says nothing about how to step — it only promises that every solver can step a field_t forward. A concrete heat_solver_t supplies the how, an explicit five-point diffusion update (the real numerics, with stability and boundary conditions, arrive in Chapter 24; here the point is the structure):

type, extends(solver_t) :: heat_solver_t
contains
  procedure :: step => heat_step
end type heat_solver_t
...
subroutine heat_step(self, fld)
  class(heat_solver_t), intent(in)    :: self
  type(field_t),        intent(inout) :: fld
  real(dp), allocatable :: u_new(:,:)
  real(dp) :: r
  integer  :: i, j
  r     = self%alpha * self%dt / fld%dx**2
  u_new = fld%u                                  ! copy keeps the fixed boundaries
  do j = 2, fld%ny - 1                           ! interior only
    do i = 2, fld%nx - 1                         ! inner loop over first index: column-major
      u_new(i,j) = fld%u(i,j) + r * ( fld%u(i-1,j) + fld%u(i+1,j) &
                 + fld%u(i,j-1) + fld%u(i,j+1) - 4.0_dp*fld%u(i,j) )
    end do
  end do
  fld%u = u_new
end subroutine heat_step

Look at where the polymorphism is and is not. The choice of solver is polymorphic — the driver holds a class(solver_t). The arithmetic is not: inside heat_step, fld%u is a plain contiguous array and the loop is a flat, monomorphic, vectorizable kernel with its inner loop over the first index, exactly as column-major layout wants. This is §10.5's rule made concrete: abstract the solver, keep the cells flat.

💡 Intuition: The abstract solver_t is a socket; heat_solver_t is one plug that fits it. The driver wires itself to the socket, and you decide at the last moment which plug to insert. Adding a wave_solver_t later means machining a new plug — writing one new extends(solver_t) type with its own step — and changing exactly one line of the driver: the allocate that chooses the plug. Nothing that talks to the socket changes.

We assemble and run this as the Project Checkpoint below. The complete, compilable framework — kinds, heat_types, solver_base, heat_solver_oo, and a driver — is in code/project-checkpoint.f90, and its hand-computed output is worked there step by step.

🔄 Check Your Understanding 1. In solver_base, why must the abstract interface for step_i contain import :: solver_t, field_t? 2. Where in this design is the dispatch, and why is that the right place for it?

Answers (1) The interface body is its own scoping unit and does not automatically see the module's solver_t type or the use-associated field_t; import brings both into scope so the argument declarations are valid. (2) The dispatch is the single call sim%step(fld) per timestep — coarse-grained, amortized over the whole grid update, so its cost is negligible; the per-cell arithmetic underneath stays monomorphic and fast, per §10.5.


Project Checkpoint

This checkpoint is an OPTIONAL, advanced track. The serial path through the book keeps the simple heat_solver module you built in Chapter 8 — a plain module with a step subroutine — and loses nothing by it. What you add here is architecture: the ability to treat your heat solver as one interchangeable member of a family of solvers behind a single interface. If your goal is one simulation, skip it; if your goal is a framework others extend, this is how it is built.

The increment. Wrap the update in the abstract solver_t of §10.6 and make heat_solver_t an extension of it. The full program in code/project-checkpoint.f90 sets up a tiny $3 \times 3$ plate, holds its top edge at $100$ and everything else at $0$, and advances the single interior point through a class(solver_t) handle:

class(solver_t), allocatable :: sim
type(field_t) :: plate
integer :: n

! ... set up plate: nx=ny=3, dx=dy=1, u=0, top edge u(1,:)=100 ...
allocate(heat_solver_t :: sim)        ! choose the heat implementation
sim%alpha = 1.0_dp
sim%dt    = 0.1_dp
do n = 1, 5
  call sim%step(plate)                ! one coarse-grained dispatch per step
end do
print '(a, f8.3)', 'interior temperature after 5 steps = ', plate%u(2,2)

With $r = \alpha\,\Delta t/\Delta x^2 = 0.1$ and only the interior point $(2,2)$ free to change (its hot neighbor above stays at $100$, its other three neighbors at $0$), the update $u \leftarrow u + r(100 - 4u)$ gives, by hand: $0 \to 10 \to 16 \to 19.6 \to 21.76 \to 23.056$. The point is heating toward its steady value of $25$ (the average of its fixed neighbors), and after five steps it reads:

$ gfortran -std=f2018 -Wall project-checkpoint.f90 -o checkpoint && ./checkpoint
heat_solver (an extension of the abstract solver_t)
interior temperature after 5 steps =   23.056

How it feeds the capstone. The class(solver_t) handle is the seam along which the rest of the book will add solvers — an implicit stepper via LAPACK (Chapter 21), a parallel variant (Part VIII) — each a new extends(solver_t) type slotted in with a one-line change at the point of choice. The Chapter 38 capstone can present its solver as one plug in a documented socket. Note again that the dispatch is once per step, never per cell: the kernel inside heat_step stays flat, as §10.5 demands.


Summary

Feature Syntax What it does
Type extension type, extends(parent_t) :: child_t Child inherits all of parent's components and bindings; may add and override.
Parent component self%parent_t%method() Calls the parent's version of an overridden binding non-polymorphically.
Polymorphic variable class(base_t), allocatable :: x Holds base_t or any extension; declared type fixed, dynamic type may vary. Local class must be allocatable/pointer/dummy.
Heterogeneous collection array of a type wrapping class(base_t), allocatable :: obj Stores a mix of dynamic types (a bare polymorphic array cannot).
Run-time type inspection select type (o => x) with type is / class is / class default Runs a block per dynamic type; type is is exact, class is matches extensions.
Abstract type type, abstract :: t Cannot be instantiated; only extended.
Deferred binding procedure(iface), deferred :: m + abstract interface A compile-time contract every concrete extension must implement.
Destructor final :: sub, with type(t), intent(inout) argument Runs just before an object is destroyed.

Rules worth memorizing:

  • type is monomorphic and fast (compile-time, inlinable); class is polymorphic and dispatched (run-time, not inlinable). Default to type; opt into class only where you need run-time variation.
  • A polymorphic array has one dynamic type; to hold a mix, make an array of boxes each wrapping a class(...) component.
  • Inside an abstract interface, you must import the host types and kinds you use.
  • A final subroutine takes type(...), not class(...). Allocatable components self-deallocate, so you rarely need one.
  • Keep class out of the hot loop. Dispatch belongs at the coarse grain (which solver), never the fine grain (which cell). Dispatch blocks inlining and vectorization.

Spaced Review

Revisiting Chapter 8 (Modules) and Chapter 9 (Derived Types) — the foundations this chapter is built on. Answer before checking.

  1. (Ch. 9) What is a type-bound procedure, and how does the passed-object dummy (self here) get its value when you write call c%describe()?

    AnswerA type-bound procedure is a procedure named in a type's `contains` section, invoked as `object%proc(...)`. By default the object before the `%` is passed as the procedure's first (passed-object) argument — so `call c%describe()` passes `c` as `self`. Type extension in this chapter builds directly on that mechanism: an extension inherits, and may override, the parent's type-bound procedures.

  2. (Ch. 8) Why must the heat_solver_t and its solver_t parent live in modules rather than being defined in a bare program? Name two things modules give you here.

    AnswerDerived types with type-bound procedures must be defined in a module (or the main program's specification part, which cannot be reused). Modules give (1) an explicit, checked interface for every procedure — the compiler verifies your `step` calls match — and (2) reuse and controlled access via `public`/`private`, so the driver `use`s only `heat_solver_t` and `solver_t` and nothing internal.

  3. (Ch. 8) In solver_base we wrote private then public :: solver_t. What does that accomplish?

    Answer`private` makes everything in the module inaccessible to users by default; `public :: solver_t` then exposes only the abstract type. Helper procedures and any internal entities stay hidden, giving a clean, minimal interface — the encapsulation modules exist to provide.

  4. (Ch. 9) The field_t type bundles nx, ny, dx, dy and an allocatable u(:,:). Why is bundling them into one derived type better than passing five separate arguments to step?

    AnswerOne object keeps the grid's data and metadata together and consistent — you cannot accidentally pass the array of one field with the dimensions of another — and it keeps procedure signatures short and stable as the field gains components. That stability is exactly what lets the `step` interface stay fixed while implementations vary.

  5. (Ch. 9) Why does heat_step declare its local u_new(:,:) as allocatable and assign u_new = fld%u rather than declaring a fixed-size array?

    AnswerThe grid size is not known at compile time; an allocatable array sized from `fld%u` (via allocation-on-assignment) adapts to whatever field it is handed and is automatically deallocated when `heat_step` returns. This is the allocatable-array habit from Chapter 5 carried into a derived-type method.

What's Next

You now have the whole object-oriented toolkit — inheritance, polymorphism, abstract interfaces, finalizers — and, just as importantly, the judgment about where to use it. One capability sits underneath all of it and deserves a chapter of its own: the indirection that makes polymorphism work, and the dynamic data structures it enables. Chapter 11 takes up pointers and targets — how => differs from =, how association status can bite you, when a linked list or tree actually beats an array in Fortran (rarely), and why, for the field in your solver, allocatable remains the right choice and pointer the wrong one. It is the last of the "how Fortran manages data" chapters before Part II turns to the practical matters of strings and error handling.