> *"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.
In This Chapter
- Overview
- Learning Paths
- 10.1 Type Extension: Inheritance with extends
- 10.2 class vs type: Polymorphism
- 10.3 select type: Recovering the Concrete Type
- 10.4 Abstract Types, deferred Bindings, and final Procedures
- 10.5 When OOP Helps — and When It Hurts
- 10.6 A Worked Framework: An Abstract solver_t
- Project Checkpoint
- Summary
- Spaced Review
- What's Next
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 fromtype. - Dispatch a call to the right implementation at run time, and unwrap a polymorphic value with
select type. - Specify an interface with an
abstracttype anddeferredbindings, so the compiler forces every implementation to conform. - Write a
finalprocedure — 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
classmust 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
extendsattribute: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_tvalue is physically ashape_twith 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 writeextends, you are using a feature that is now old enough to vote.🔄 Check Your Understanding 1. A
circle_textendsshape_t. Does acircle_tvalue have alabelcomponent? Where did it come from? 2. Insidecircle_describe, what doesself%shape_t%describe()call, and why would you write it? 3. Can you call%area()on a variable declaredtype(shape_t)? Why or why not?
Answers
(1) Yes —labelis inherited fromshape_t; extension is additive, so the child has every component of the parent without redeclaring it. (2) It calls the parent'sdescribe(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 —areais a binding ofcircle_t, not ofshape_t. Ashape_thas noareaprocedure, 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 declaredclass(base_t)is polymorphic: at run time it may hold a value ofbase_tor 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). Contrasttype(base_t), which is monomorphic — it is always exactlybase_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 polymorphics, the call dispatches to the area procedure of whateverscurrently 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, notallocatable, not apointer— is not allowed. Polymorphism needs a level of indirection to work, so a polymorphic non-dummy object must beallocatableorpointer. This is fine:fortran class(shape_t), allocatable :: s ! OK: allocatable polymorphicand this will not compile:fortran class(shape_t) :: s ! ERROR: local class must be allocatable/pointer/dummyDummy arguments are the common exception:subroutine f(self); class(shape_t), intent(in) :: selfneeds noallocatable— the caller's storage supplies the indirection. That is why every type-bound procedure's passed object isclass, nottype: 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 shapeThe box is monomorphic (all boxes areshape_box_t); the polymorphism lives one level down, in each box'sobj. 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. Monomorphictypeis the norm — no lookup, the compiler inlines the call — and you opt in to run-time dispatch withclassonly 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) :: sas a local variable, and what two attributes fix it? 3. Why doesclass(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 atype(...)variable they are always the same; forclass(...)they may differ. (2) A polymorphic object needs indirection to hold different types; a plain local has none. Declare itallocatableorpointer(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. Atype is (t)guard matches when the dynamic type is exactlyt; aclass is (t)guard matches when the dynamic type istor any extension of it; aclass defaultguard 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 typeis often a design smell. Everyselect typeis a place where you stopped trusting polymorphism and started asking "what are you, really?" A long chain oftype 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 adeferredbinding (§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 sameselect typein three places, you have found three places that should have been a method.🐛 Find the Bug. A reader writes this to handle a
square_tthat extendsrectangle_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 selectThey complain thetype 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 asquare_twould in fact selecttype is (square_t)overclass is (rectangle_t). The real trap is the opposite worry: mixingclass isandtype ismakes 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 atype isguard for each concrete type you actually handle, and reserveclass is/class defaultfor the genuine "everything else" case. If you need per-type behavior for square and rectangle, that behavior wants to be adeferredbinding, not aselect type.🔄 Check Your Understanding 1. What is the difference between
type is (circle_t)andclass is (circle_t)in aselect type? 2. Why is aselect typechain that implements behavior often a sign you should have used adeferredbinding?
Answers
(1)type is (circle_t)matches only when the dynamic type is exactlycircle_t;class is (circle_t)matchescircle_tand any type that extends it. (2) Because adding a new type then forces you to edit every such chain by hand, whereas adeferredbinding 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 :: tthat cannot be instantiated — you may not declare atype(t)variable orallocateone — and exists only to be extended. It defines the common components and the interface (viadeferredbindings) that its extensions must fulfill. A polymorphicclass(t)variable is still fine; it just always holds some concrete extension.Definition (deferred binding). A type-bound procedure declared
deferredin 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'scontainssection under thefinal ::keyword, called automatically ("finalized") just before an object of that type is destroyed — bydeallocate, 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 astype(...), notclass(...)(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
finalfar less than you think, and gfortran's support has historically had gaps. Notice thatmanaged_t'sdata(:)isallocatable. When amanaged_tis destroyed, Fortran automatically deallocates its allocatable components — you do not need a finalizer to freedata. This is the great advantage ofallocatablecomponents over pointer components (Chapter 9) and overpointers (Chapter 11): they clean up after themselves. Afinalprocedure earns its keep only for resources Fortran does not manage — an open file unit, a Cmalloc, 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 — explicitdeallocate, 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)onceshape_tisabstract? What can you declare? 2. What forcescircle_tto provide anareaprocedure, and when is that enforced? 3. Why does amanaged_twith anallocatablecomponent rarely need afinalprocedure to free it?
Answers
(1) An abstract type cannot be instantiated, sotype(shape_t)is illegal; you may declareclass(shape_t), allocatable(orpointer), which always holds a concrete extension. (2) Thedeferredbinding 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
deferredbinding 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 typechain. 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
classvariable 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
classout 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 aclass(solver_t)once, before the time loop, and dispatch to itssteponce 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 plaintype, 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_tis a socket;heat_solver_tis one plug that fits it. The driver wires itself to the socket, and you decide at the last moment which plug to insert. Adding awave_solver_tlater means machining a new plug — writing one newextends(solver_t)type with its ownstep— and changing exactly one line of the driver: theallocatethat 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 theabstract interfaceforstep_icontainimport :: 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'ssolver_ttype or the use-associatedfield_t;importbrings both into scope so the argument declarations are valid. (2) The dispatch is the singlecall 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:
typeis monomorphic and fast (compile-time, inlinable);classis polymorphic and dispatched (run-time, not inlinable). Default totype; opt intoclassonly 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 mustimportthe host types and kinds you use. - A
finalsubroutine takestype(...), notclass(...). Allocatable components self-deallocate, so you rarely need one. - Keep
classout 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.
-
(Ch. 9) What is a type-bound procedure, and how does the passed-object dummy (
selfhere) get its value when you writecall c%describe()?
Answer
A 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. -
(Ch. 8) Why must the
heat_solver_tand itssolver_tparent live in modules rather than being defined in a bare program? Name two things modules give you here.
Answer
Derived 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. -
(Ch. 8) In
solver_basewe wroteprivatethenpublic :: 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. -
(Ch. 9) The
field_ttype bundlesnx, ny, dx, dyand an allocatableu(:,:). Why is bundling them into one derived type better than passing five separate arguments tostep?
Answer
One 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. -
(Ch. 9) Why does
heat_stepdeclare its localu_new(:,:)asallocatableand assignu_new = fld%urather than declaring a fixed-size array?
Answer
The 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.