33 min read

> "Write programs to handle text streams, because that is a universal interface."

Prerequisites

  • 3
  • 5
  • 6
  • 7

Learning Objectives

  • Distinguish fixed-length from deferred-length (character(:), allocatable) strings, and predict len, len_trim, and allocated for each.
  • Apply the character-intrinsic family — len, len_trim, trim, adjustl, adjustr, index, scan, verify — and compute each return value by hand.
  • Build strings with concatenation (//) and extract parts with substring notation s(i:j), including the deferred-length growth idiom.
  • Convert between numbers and text in memory with internal files, using the edit descriptors from Chapter 7.
  • Parse a line of text into tokens with a scan/verify loop, and explain why it handles runs of delimiters correctly.
  • Contrast modern string handling with the FORTRAN 77 fixed-length approach and its manual length bookkeeping.

Chapter 12: String Handling, Characters, and Modern Text Processing

"Write programs to handle text streams, because that is a universal interface." — Douglas McIlroy, on the Unix philosophy

Overview

Fortran's reputation for handling text is terrible, and — like most of Fortran's bad reputation — it is forty years out of date. The FORTRAN 77 that people remember gave you exactly one kind of string: a fixed block of characters, declared at a fixed length, that you padded with blanks and whose real content you tracked yourself in a separate integer. Building a filename, trimming a stray blank, or splitting a line into words meant a small ritual of counting and slicing that was genuinely unpleasant. That is the Fortran of the caricature, and if it is the only Fortran you have seen, you would reasonably conclude the language cannot do text.

Modern Fortran can. This chapter gives you three tools that, together, make text handling clean: strings that size themselves to their contents (the deferred-length string, which is nothing more than the allocatable idea from Chapter 5 and Chapter 11 applied to characters); a family of intrinsic functions that search, trim, justify, and validate text without a hand-written loop in sight; and internal files, which let you use the full formatting machinery of Chapter 7 to turn numbers into text and text back into numbers, entirely in memory. None of this is exotic. It is the everyday plumbing of scientific code — reading a configuration line, labeling a plot, writing the filename heat_000123.vtk for the 123rd frame of your simulation — and by the end of the chapter you will do all of it comfortably.

In this chapter, you will learn to:

  • Declare and use fixed-length and deferred-length strings, and know exactly what len, len_trim, trim, and allocated report for each.
  • Reach for the right character intrinsicindex, scan, verify to search; trim, adjustl, adjustr to clean up — and compute what each one returns before you compile.
  • Concatenate with //, slice out a substring with s(i:j), and grow a deferred-length string by assignment.
  • Convert numbers to text and text to numbers with internal files, the in-memory cousin of file I/O.
  • Write a small tokenizer that splits a line into words, and understand why it survives extra spaces.
  • See, side by side, why modern Fortran's strings are a genuine pleasure and the FORTRAN 77 way was a chore — one more instance of the theme that modern Fortran is a modern language.

Learning Paths

How to read this chapter by track. - 🔬 Scientist ("I just need to read inputs and label outputs") — §12.1, §12.2, and §12.4 are your core: deferred-length strings, the trim/search intrinsics, and internal files for number↔text. The Project Checkpoint (indexed filenames) is exactly what your output loop needs. - 📖 Standard — read straight through; strings are a small, self-contained corner of the language and this is the whole of it. - 🔧 Legacy ("I inherited old code") — §12.1 and §12.6 are your orientation: you will meet fixed-length buffers with manual length counters everywhere in old code, and here is what they become. - ⚡ HPC ("I need parallel code") — text is rarely your bottleneck, so skim, but read §12.4: internal files are how you build per-rank, per-timestep output filenames without touching the disk in the hot loop.


12.1 Two Kinds of String: Fixed-Length and Deferred-Length

A Fortran string is a value of the character type you met in Chapter 3. What is new here is that a string has a second attribute besides its type — its length, the number of characters it holds — and there are two ways to decide that length. The choice between them is the whole of this section, and it echoes a choice you have already made twice: static versus dynamic, fixed versus allocatable.

The old way, and still the right way for a great many uses, is the fixed-length string.

Definition (fixed-length character). A fixed-length string is declared with a length that is fixed for the variable's whole life: character(len=10) :: name. It always holds exactly that many characters. Assign it a shorter value and it is padded on the right with blanks to fill the declared length; assign it a longer value and the value is truncated to fit. The length is a property of the variable, not of whatever you store in it.

That blank-padding rule is the source of every fixed-length surprise, so meet it directly:

program fixed_length
  implicit none
  character(len=10) :: name = 'Fortran'    ! 7 chars stored, 3 trailing blanks added
  print '(a, i0)', 'len(name)      = ', len(name)        ! 10  — the declared length
  print '(a, i0)', 'len_trim(name) = ', len_trim(name)   ! 7   — ignoring trailing blanks
  print '(a)',     'name = "' // name // '"'              ! "Fortran   " (three blanks)
end program fixed_length
$ gfortran -std=f2018 -Wall fixed_length.f90 -o fixed && ./fixed
len(name)      = 10
len_trim(name) = 7
name = "Fortran   "

The variable is length 10 forever. 'Fortran' occupies the first seven characters; the last three are blanks you did not ask for but got anyway. len(name) reports the declared length, 10 — it is a compile-time constant here and does not depend on the contents. len_trim(name) reports the length ignoring trailing blanks, which is the 7 you probably meant. That gap between len and len_trim is the thing you manage by hand in fixed-length code, and it is exactly the bookkeeping deferred-length strings abolish.

Definition (deferred-length character). A deferred-length string is declared character(:), allocatable :: s — the (:) says "the length is not fixed here; defer it." Like an allocatable array, it starts life unallocated, and it acquires a length either from an explicit allocate(character(len=n) :: s) or, far more commonly, from assignment: writing s = 'Fortran' automatically allocates s to length 7 and stores the value with no padding. Assign it again and it reallocates to the new length. len(s) then always reports the current, exact length, and allocated(s) tells you whether it has one yet.

The same program, done the modern way, has no gap to manage:

program deferred_length
  implicit none
  character(:), allocatable :: s
  print '(a, l1)', 'allocated(s) at start = ', allocated(s)   ! F — no length yet
  s = 'Fortran'
  print '(a, i0)', 'len(s) after assign   = ', len(s)          ! 7  — exactly the content
  s = s // ' 2018'                                              ! grow it by concatenation
  print '(a, i0)', 'len(s) after grow     = ', len(s)          ! 12
  print '(a)',     's = "' // s // '"'                          ! "Fortran 2018" — no blanks
end program deferred_length
$ gfortran -std=f2018 -Wall deferred_length.f90 -o deferred && ./deferred
allocated(s) at start = F
len(s) after assign   = 7
len(s) after grow     = 12
s = "Fortran 2018"

Trace the lengths. Before assignment s is unallocated, so allocated(s) is F and asking for len(s) would be meaningless — just as for an unallocated array. After s = 'Fortran' the string is allocated to length 7, holding exactly Fortran with no trailing blanks. Then s = s // ' 2018' builds the twelve-character value 'Fortran 2018' on the right-hand side and assigns it back; because s is allocatable, it silently reallocates from length 7 to length 12 to fit. There is no declared length to overflow and no padding to trim. The string is always precisely as long as its contents.

🚪 Threshold Concept — a string is an allocatable array of characters. Everything that made allocatable arrays pleasant in Chapter 5 — you do not pre-guess the size, the object owns its memory, it is freed automatically when it dies, and assignment reallocates to fit — is true, unchanged, for character(:), allocatable. A deferred-length string is a dynamically sized, self-managing object; it just happens to be made of characters instead of real(dp)s. Once you see strings this way, you stop reaching for oversized fixed buffers "to be safe," because the safe thing already sizes itself.

The automatic-reallocation-on-assignment behavior is the Fortran 2003 rule, and gfortran gives it to you by default at -std=f2018 (it is the same rule that lets b = a resize an allocatable array). Older code compiled with -std=f95, or a compiler in a legacy mode, may not do it — a portability wrinkle worth knowing, and the reason a very old codebase pre-sizes everything.

Passing strings to procedures: assumed length

One more declaration completes the picture, because you will use it in every string routine you write. When a procedure accepts a string argument, it almost never wants to fix the caller's length; it wants to accept a string of whatever length the caller has. That is the assumed-length dummy, written character(len=*):

subroutine announce(label)
  character(len=*), intent(in) :: label   ! takes the caller's length, whatever it is
  print '(a)', 'processing: ' // trim(label)
end subroutine announce

The len=* says "the length is assumed from the actual argument." It is the character analog of the assumed-shape array argument from Chapter 6: one routine, any length. You will see character(len=*), intent(in) on essentially every string dummy in this chapter and the rest of the book.

🐍 Python Comparison: A Python str is dynamic, immutable, and knows its own length — much like a deferred-length Fortran string, and nothing like a fixed-length one. The FORTRAN 77 fixed-length string, with its trailing blanks and separate length counter, has no real Python equivalent; the closest is a fixed-size bytearray you slice by hand. So the mental model to import from Python is character(:), allocatable, not character(len=n) — the deferred-length string is the one that behaves the way you already expect a string to behave.


Fortran gives you a compact set of intrinsic functions for text, and they divide cleanly into three jobs: measuring a string (len, len_trim), cleaning it up (trim, adjustl, adjustr), and searching it (index, scan, verify). Learn the eight of them and you can do most of what daily text handling asks. Because they are intrinsic, the compiler implements them efficiently — there is no hand-written character loop for you to get wrong.

Definition (the trim/adjustl family — character intrinsics). These operate on a character value and return either an integer position/length or a new character value: - len(s) — the declared/allocated length, including trailing blanks. - len_trim(s) — the length excluding trailing blanks (0 if s is all blanks). - trim(s) — a copy of s with trailing blanks removed (leading blanks are kept); its length is len_trim(s). - adjustl(s)left-justify: remove leading blanks and append that many blanks at the end; the length is unchanged. - adjustr(s)right-justify: remove trailing blanks and prepend that many blanks at the front; the length is unchanged. - index(string, sub [, back]) — the 1-based position where sub first occurs in string, or 0 if it does not; with back=.true., the last occurrence. - scan(string, set [, back]) — the position of the first character of string that is one of the characters in set, or 0 if none; with back, the last. - verify(string, set [, back]) — the position of the first character of string that is not in set, or 0 if every character is in set; with back, the last.

That is a lot of behavior stated abstractly, so pin it down with one program whose every line you can check against the definitions:

program string_intrinsics
  implicit none
  character(len=12) :: s   = 'Fortran 90'    ! 10 chars stored, 2 trailing blanks
  character(len=6)  :: pad = '  ab  '         ! 2 leading, 'ab', 2 trailing

  ! measure
  print '(a, i0)', 'len(s)             = ', len(s)                 ! 12
  print '(a, i0)', 'len_trim(s)        = ', len_trim(s)            ! 10

  ! search with index: 1-based position, or 0 if absent
  print '(a, i0)', 'index(s,"r")       = ', index(s, 'r')          ! 3
  print '(a, i0)', 'index(s,"r",back)  = ', index(s, 'r', .true.)  ! 5
  print '(a, i0)', 'index(s,"ran")     = ', index(s, 'ran')        ! 5
  print '(a, i0)', 'index(s,"z")       = ', index(s, 'z')          ! 0

  ! scan finds a char IN the set; verify finds the first char NOT in the set
  print '(a, i0)', 'scan("key=val","=")= ', scan('key=val', '=')   ! 4
  print '(a, i0)', 'verify("2018",dig) = ', verify('2018', '0123456789')   ! 0
  print '(a, i0)', 'verify("20x8",dig) = ', verify('20x8', '0123456789')   ! 3

  ! clean up (length is preserved; blanks are moved, not deleted)
  print '(a)', 'adjustl(pad)  = "' // adjustl(pad) // '"'   ! "ab    "
  print '(a)', 'adjustr(pad)  = "' // adjustr(pad) // '"'   ! "    ab"
  print '(a)', 'trim(adjustl) = "' // trim(adjustl(pad)) // '"'  ! "ab"
end program string_intrinsics
$ gfortran -std=f2018 -Wall string_intrinsics.f90 -o intr && ./intr
len(s)             = 12
len_trim(s)        = 10
index(s,"r")       = 3
index(s,"r",back)  = 5
index(s,"ran")     = 5
index(s,"z")       = 0
scan("key=val","=")= 4
verify("2018",dig) = 0
verify("20x8",dig) = 3
adjustl(pad)  = "ab    "
adjustr(pad)  = "    ab"
trim(adjustl) = "ab"

Walk the tricky lines. The string s is 'Fortran 90' stored in a length-12 variable, so it is F o r t r a n _ 9 0 followed by two blanks — hence len = 12 but len_trim = 10. For index, number the characters 1:F 2:o 3:r 4:t 5:r 6:a 7:n: the substring 'r' first appears at position 3 and last at position 5, and 'ran' (r-a-n) matches at position 5 (there is no earlier r-a-n; position 3 is r-t-r), while 'z' never appears, giving 0. For scan('key=val','='), number 1:k 2:e 3:y 4:=: the first character that is in the set '=' is the = at position 4. For verify, the set is the ten digits: '2018' is entirely digits so the first non-digit does not exist and the answer is 0, while '20x8' has its first non-digit — the x — at position 3. And for the justifiers, pad is ' ab ': adjustl slides the content left, moving the two leading blanks to the tail ('ab '); adjustr slides it right, moving the two trailing blanks to the front (' ab'); both keep the length at 6. Wrapping trim around adjustl deletes the now-trailing blanks, leaving 'ab' — the standard idiom for stripping blanks from both ends at once.

Three of these earn special mention because they are the ones you will reach for constantly.

trim is how you undo blank-padding. Any time you take a value out of a fixed-length variable and want just its content — to concatenate it, print it, or use it as a filename — trim is the tool. It removes trailing blanks only; if you also have leading blanks, trim(adjustl(s)) removes both.

index is substring search, and it is 1-based. It answers "where does this appear?" and returns 0 for "nowhere." That 0 is a genuine position-that-cannot-occur (positions start at 1), so if (index(s,sub) > 0) reads as "if sub is present." Splitting key=value on its = is index(line, '=').

scan and verify are the underrated pair. scan finds "any character from this set" — the first delimiter, the first digit, the first vowel. verify finds "the first character not from this set," which is exactly how you validate: verify(token, '0123456789') == 0 is the clean test for "this token is all digits," and verify(s, ' ') is "the first non-blank," i.e. where the content starts. You will build a tokenizer out of scan and verify in §12.5, and once you have, you will see them everywhere.

⚠️ Common Pitfall — index returns a position; don't test it as a boolean. index(s, sub) returns an integer position, 0 when absent. A frequent slip is to write if (index(s, sub)) — which does not even compile in Fortran, because if needs a logical, not an integer (a mercy; in C the analogous mistake compiles and misbehaves). Write the comparison you mean: if (index(s, sub) > 0) for "present," or if (index(s, sub) == 0) for "absent." Likewise scan and verify return positions, not flags.

🐍 Python Comparison: The map to Python's str methods is close but not exact, and the differences bite. index(s, sub) is like s.find(sub) — but Fortran is 1-based and returns 0 when absent, while Python is 0-based and returns −1 when absent, so a mechanical port that forgets to shift the base and the sentinel will be wrong in two ways at once. trim(adjustl(s)) is s.strip(); trim(s) is s.rstrip(). scan/verify have no single-method Python equivalent — you would reach for a regular expression or a comprehension — which is a rare case of Fortran expressing something more concisely than Python.

🔄 Check Your Understanding. 1. For character(len=8) :: t = 'grid', what are len(t), len_trim(t), and trim(t)? 2. What does index('a.b.c', '.', back=.true.) return, and what everyday parsing task is that? 3. Write the one-line test for "the string tok contains only the digits 0–9."

Answers 1. len(t) = 8 (declared length), len_trim(t) = 4 ('grid' has 4 non-blank chars), trim(t) = 'grid' (length 4). The variable holds 'grid ' with four trailing blanks. 2. It returns 4. Number the characters 1:a 2:. 3:b 4:. 5:c: the dots are at positions 2 and 4, so the last one — what back=.true. asks for — is at position 4. Finding the last dot is how you locate a file extension's separator. 3. verify(tok, '0123456789') == 0 — zero means "no character fell outside the digit set."


12.3 Concatenation and Substrings

Two operations let you take strings apart and put them together, and both are pleasantly simple.

Concatenation joins strings with the // operator:

Definition (concatenation). The concatenation operator // joins two character values into one whose length is the sum of the two lengths. 'heat' // '.vtk' is 'heat.vtk' (length 8). It concatenates the operands exactly as given, blanks and all — so name // '.vtk' where name is a fixed-length variable will splice in name's trailing blanks unless you trim it first.

Substrings extract a contiguous run of characters by position:

Definition (substring). A substring is a piece of a string selected by a position range: s(i:j) is the characters of s from position i through position j inclusive, a string of length j - i + 1. The bounds are 1-based, exactly like array indices. Omitting a bound means "to the end" or "from the start": s(i:) runs from i to the last character, s(:j) from the first through j. If i > j, the substring has length zero. A substring is a first-class string value: you can print it, concatenate it, search it, or (if s is a variable) assign into it.

Both in one short program:

program concat_substring
  implicit none
  character(len=20) :: filename = 'heat_000123.vtk'
  integer :: dot

  ! concatenation builds a new string
  print '(a)', 'joined = "' // 'heat_' // '000123' // '.vtk' // '"'   ! "heat_000123.vtk"

  ! substrings pull pieces out, 1-based and inclusive
  print '(a)', 'prefix = "' // filename(1:5)  // '"'    ! "heat_"
  print '(a)', 'stamp  = "' // filename(6:11) // '"'    ! "000123"

  ! find the extension by locating the last '.'
  dot = index(trim(filename), '.', back=.true.)
  print '(a, i0)', 'dot at position ', dot                ! 12
  print '(a)', 'ext    = "' // trim(filename(dot+1:)) // '"'   ! "vtk"
end program concat_substring
$ gfortran -std=f2018 -Wall concat_substring.f90 -o cs && ./cs
joined = "heat_000123.vtk"
prefix = "heat_"
stamp  = "000123"
dot at position 12
ext    = "vtk"

filename holds 'heat_000123.vtk' (15 characters) in a length-20 variable. filename(1:5) is the five-character prefix 'heat_'; filename(6:11) is the six-character stamp '000123'. To find the extension we locate the last dot with index(..., back=.true.) — numbering heat_000123.vtk gives the . at position 12 — then take the substring from dot+1 to the end, filename(13:), and trim off the padding blanks to get 'vtk'. This is real filename parsing, and it is three intrinsics.

💡 Intuition: substring notation s(i:j) is deliberately identical to array-section notation a(i:j) — because a string is an array of characters, and Fortran wants the two to feel the same. Reach for the same instincts: 1-based, inclusive on both ends, and a missing bound means "the rest."

⚠️ Common Pitfall — concatenating fixed-length variables splices in the blanks. This is the single most common string bug in scientific code. If dir is character(len=20) holding 'output', then dir // '/data.txt' is not 'output/data.txt' — it is 'output', then fourteen blanks, then '/data.txt', because the fixed-length dir carries its padding into the concatenation. Always trim a fixed-length variable before you join it: trim(dir) // '/data.txt'. (Deferred-length strings, having no padding, do not have this problem — one more reason to prefer them.)

🐛 Find the Bug. A build routine assembles an output path and the file lands in the wrong place:

fortran character(len=20) :: outdir = 'output' character(len=40) :: path path = outdir // '/' // 'heat.vtk' print '(a)', 'writing to: ' // trim(path)

The program prints writing to: output /heat.vtk. What happened, and what is the fix?

Diagnosis outdir is a fixed-length variable of length 20, so it holds 'output' followed by fourteen trailing blanks. Concatenation splices those blanks in verbatim, so outdir // '/' is 'output' + 14 blanks + '/', and the path contains a run of spaces before the slash. The trailing trim(path) only removes blanks at the very end, not the ones buried in the middle. The fix is to trim the fixed-length piece before joining: path = trim(outdir) // '/' // 'heat.vtk', giving 'output/heat.vtk'. Better still, make outdir deferred-length so there are no blanks to trim.


12.4 Internal Files: Numbers to Text and Back

Sooner or later every program has to cross the line between numbers and their textual representation. You have an integer step counter and you need the string '000123' to build a filename. You have read a configuration line as text and you need the number it represents. You want to assemble a log message mixing words and values. In C this is sprintf and sscanf; in Python it is str, int, float, and f-strings. Fortran's answer is the internal file, and its great virtue is that it reuses machinery you already know: the formatted I/O and edit descriptors of Chapter 7, pointed at a string instead of a disk file.

Definition (internal file). An internal file is a character variable used as the "unit" in a read or write statement. Writing to it — write(s, fmt) values — formats the values into the characters of s exactly as they would be formatted into a line of a text file, but the result stays in memory as the string s. Reading from it — read(s, fmt) variables — parses the characters of s as if reading a line from a file, converting text to numbers. No actual file, no open, no close, no disk: the string is the file. It is Fortran's in-memory bridge between numbers and text, with the full power of edit descriptors on both sides.

Number → text (internal write)

To turn a number into a string, write it to a character variable with a format:

program num_to_text
  use, intrinsic :: iso_fortran_env, only: dp => real64
  implicit none
  character(len=32) :: buf
  integer  :: step
  real(dp) :: x

  step = 123
  write(buf, '(a, i6.6, a)') 'heat_', step, '.vtk'   ! -> 'heat_000123.vtk'
  print '(a)', 'filename = "' // trim(buf) // '"'

  x = 3.14159_dp
  write(buf, '(f8.3)') x                              ! -> '   3.142' (width 8)
  print '(a)', 'x label  = "' // trim(adjustl(buf)) // '"'   ! strip the leading blanks

  write(buf, '(i0)') 255                              ! -> '255' (minimum width)
  print '(a)', 'count    = "' // trim(buf) // '"'
end program num_to_text
$ gfortran -std=f2018 -Wall num_to_text.f90 -o n2t && ./n2t
filename = "heat_000123.vtk"
x label  = "3.142"
count    = "255"

The first write is the one that matters most, and it is the Project Checkpoint in miniature. The format '(a, i6.6, a)' says: emit the string 'heat_' (the first a), then the integer step in a field six wide with a minimum of six digits, zero-padded (i6.6), then the string '.vtk'. With step = 123, the i6.6 produces '000123' — three significant digits, zero-filled to six — so buf receives 'heat_000123.vtk' (fifteen characters) followed by blanks to fill its length of 32. trim removes the padding. That zero-padding is precisely what makes filenames sort correctly: heat_000123.vtk sorts before heat_001000.vtk, whereas the unpadded heat_123.vtk and heat_1000.vtk would not.

The f8.3 conversion formats 3.14159 to three decimals in a field eight wide, right-justified: ' 3.142'. Because the field is wider than the number, it comes out with leading blanks; adjustl slides it to the left and trim removes the resulting trailing blanks, leaving '3.142'. And i0 is the integer descriptor with minimum width — no leading blanks at all — giving '255' directly, which is why i0 is the everyday choice for embedding an integer in a message.

⚠️ Common Pitfall — a too-narrow field prints asterisks. The i6.6 above holds any step from 0 to 999999. Hand it a seven-digit number and the six-wide field cannot contain it, so Fortran fills the field with asterisks — write(buf,'(i6.6)') 1234567 yields '******', not a truncated or widened number. This is Fortran refusing to silently corrupt your output, and it is a good failure (a filename full of * is obviously wrong), but it means you must size the field for the largest value you will ever format. For a run of at most a million steps, i6.6 is exactly right; for more, widen it. When you want the field to always fit, use i0, which never overflows because it sizes itself.

Text → number (internal read)

The reverse direction parses text into numbers. List-directed input (the * format from Chapter 7) is the easy way — it splits on blanks and commas and converts each field:

program text_to_num
  use, intrinsic :: iso_fortran_env, only: dp => real64
  implicit none
  character(len=32) :: line
  integer  :: a, b, c
  real(dp) :: x

  line = '3 14 159'
  read(line, *) a, b, c                    ! list-directed: split on blanks
  print '(a, i0, 1x, i0, 1x, i0)', 'ints = ', a, b, c    ! 3 14 159

  line = '2.71828'
  read(line, *) x
  print '(a, f7.5)', 'real = ', x                          ! 2.71828
end program text_to_num
$ gfortran -std=f2018 -Wall text_to_num.f90 -o t2n && ./t2n
ints = 3 14 159
real = 2.71828

Reading '3 14 159' list-directed into three integers splits the string on its blanks and converts each field, giving a = 3, b = 14, c = 159. Reading '2.71828' into a real gives 2.71828. This is how you consume a data line whose values are separated by spaces without writing a parser — the internal read is the parser. When the text might be malformed, add the iostat= specifier from Chapter 7 exactly as you would for a real file, and check it; a bad conversion sets iostat nonzero instead of crashing, and Chapter 13 makes a discipline of that check.

🔗 Connection: internal files are the same read/write statements and the same edit descriptors you met for real files in Chapter 7 — the only change is that the unit is a character variable rather than a unit number from open. That is the whole idea: Fortran did not invent a separate string-formatting sublanguage (as C did with printf format strings, or Python with its own mini-language); it pointed the I/O system it already had at a string. If you can format it to a file, you can format it to a string, with identical syntax.

🔄 Check Your Understanding. 1. What string does write(buf,'(a,i4.4)') 'run', 7 place in buf (before padding)? 2. Why does i0 never overflow into asterisks, while i4 can? 3. You have character(len=20) :: line = '42 3.5'. Write the statement that reads the 42 into integer n and the 3.5 into real(dp) :: r.

Answers 1. 'run0007' — the a emits 'run', and i4.4 emits 7 in a 4-wide field zero-padded to 4 digits, '0007'. 2. i0 uses the minimum width needed for the value, so it always fits by construction; i4 fixes the width at 4, and a value needing 5 or more digits cannot fit, so the field is filled with . 3. read(line, ) n, r — list-directed internal read splits on the blank, converting 42 to the integer and 3.5 to the real.


12.5 Parsing Text: A Small Tokenizer

Configuration files, data files, and command lines all arrive as lines of text that you must split into their pieces — the tokens. A tokenizer is the small workhorse that does this, and building one ties together everything in the chapter: scan and verify to find boundaries, substrings to extract, and an assumed-length argument to accept any line.

Definition (tokenizer). A tokenizer scans a string and splits it into a sequence of tokens — the meaningful units (words, numbers, symbols) — separated by delimiters (here, blanks). A good tokenizer treats a run of delimiters as a single separator, so extra spaces between tokens do not produce empty tokens.

The strategy is a two-step loop: use verify to skip any delimiters and find where the next token starts, then use scan to find where it ends (the next delimiter, or the end of the line). Both searches run on the remaining substring line(pos:), so we convert their offsets back to absolute positions:

program tokenizer
  implicit none
  call tokenize('the quick  brown')      ! note the double space
contains
  subroutine tokenize(line)
    character(len=*), intent(in) :: line
    character(len=1), parameter  :: blank = ' '
    integer :: pos, first, last, ntok

    pos  = 1
    ntok = 0
    do
      if (pos > len(line)) exit
      first = verify(line(pos:), blank)     ! offset of first non-blank in the remainder
      if (first == 0) exit                   ! only blanks left -> done
      first = pos + first - 1                ! absolute start of the token
      last  = scan(line(first:), blank)      ! offset of the next blank after the token
      if (last == 0) then
        last = len(line)                     ! no more blanks: token runs to the end
      else
        last = first + last - 2              ! absolute position of the token's last char
      end if
      ntok = ntok + 1
      print '(a, i0, a, a)', 'token ', ntok, ': ', line(first:last)
      pos = last + 1                          ! resume just past this token
    end do
  end subroutine tokenize
end program tokenizer
$ gfortran -std=f2018 -Wall tokenizer.f90 -o tok && ./tok
token 1: the
token 2: quick
token 3: brown

Trace it on 'the quick brown' (length 16, with two blanks between quick and brown), because the index arithmetic is the kind of thing you should never take on faith:

  • pos = 1. verify(line(1:), ' ') finds the first non-blank at offset 1 (t), so first = 1. scan(line(1:), ' ') finds the first blank at offset 4, so last = 1 + 4 - 2 = 3. Token = line(1:3) = 'the'. Advance pos = 4.
  • pos = 4. line(4:) is ' quick brown'; verify skips the single leading blank and reports offset 2, so first = 4 + 2 - 1 = 5 (the q). scan(line(5:), ' ') finds the blank after quick at offset 6, so last = 5 + 6 - 2 = 9 (the k). Token = line(5:9) = 'quick'. Advance pos = 10.
  • pos = 10. line(10:) is ' brown'; verify skips both blanks and reports offset 3, so first = 10 + 3 - 1 = 12 (the b). scan(line(12:), ' ') finds no blank and returns 0, so last = len(line) = 16. Token = line(12:16) = 'brown'. Advance pos = 17.
  • pos = 17 > 16, so the loop exits. Three tokens.

The double space between quick and brown produced no empty token, because verify skips a run of blanks in one step — that is the whole reason we search with verify rather than just splitting at every blank. Change blank to a set like ' ,' and the same loop tokenizes comma-or-space separated data; that generality is why scan/verify take a set of characters rather than a single one.

💡 Intuition: the two searches answer complementary questions. verify(line(pos:), blank) asks "where does the next token begin?" (the first non-delimiter); scan(line(first:), blank) asks "where does it end?" (the first delimiter after it). Skip-then-take, skip-then-take, until nothing but delimiters remains. Every hand-rolled tokenizer in every language is some version of this loop; Fortran just hands you scan and verify so the two searches are one call each.

⚠️ Common Pitfall — the off-by-one when converting offsets to positions. verify and scan here search the substring line(pos:), so they return an offset relative to pos, not an absolute position in line. The conversion is absolute = pos + offset - 1. Forgetting the - 1 (or the - 2 when the end is one before the delimiter) shifts every token by a character — the classic fencepost error. When your tokens come out with a stray leading character or a missing final one, this arithmetic is the first place to look. Hand-tracing three iterations, as above, catches it every time.

🔄 Check Your Understanding. 1. Why does the tokenizer use verify to find the start of a token but scan to find the end? 2. What single change makes it split on commas as well as spaces? 3. On input ' hi' (two leading blanks), what is first on the first iteration, and what token results?

Answers 1. A token starts at the first character that is not a delimiter — that is exactly what verify finds. A token ends at the first character that is a delimiter — that is what scan finds. The two intrinsics are duals, and the tokenizer uses each for the boundary it locates. 2. Change the delimiter to a set containing both: use a two-character set ' ,' in place of the single blank (declare it character(len=2), parameter :: seps = ' ,' and search with seps). scan/verify already take a set, so nothing else changes. 3. verify(' hi', ' ') returns offset 3 with pos = 1, so first = 1 + 3 - 1 = 3; scan(line(3:), ' ') returns 0, so last = 4, and the token is line(3:4) = 'hi'. Leading blanks are skipped, exactly as intended.


12.6 Modern Strings vs the FORTRAN 77 Nightmare

It is worth seeing what all of this replaces, both to appreciate the modern tools and because you will inherit the old ones. FORTRAN 77 had a CHARACTER type — its addition in the 1977 standard was itself a big deal, the first time the language handled text as a first-class type — but every string was fixed-length. There was no deferred-length string, no automatic reallocation, no concatenating your way to a right-sized result. To build a string whose final length you did not know in advance, you declared a buffer big enough for the worst case and tracked the used length yourself in a companion integer.

Here is the FORTRAN 77 way to build a name like heat_000123.vtk and keep its true length — written in modern free-form so you can read it, but in the old style of manual bookkeeping:

! Legacy style: a fixed buffer plus a hand-maintained length counter.
character(len=64) :: name          ! "big enough, I hope"
integer :: n                        ! the used length, tracked by hand
name = ' '                          ! must blank the whole buffer first
name(1:5) = 'heat_'
n = 5
write(name(n+1:n+6), '(i6.6)') step   ! place the digits by absolute position
n = n + 6
name(n+1:n+4) = '.vtk'
n = n + 4
! the "real" string is name(1:n); every use must remember to slice it
print '(a)', 'file: ' // name(1:n)

Every line carries risk. The buffer might be too small (silent truncation). You must blank it before use or leftover characters leak through. You compute every insertion position by hand. And forever after, the string's true content is name(1:n) — pass name anywhere without the (1:n) and you pass the trailing blanks too. The separate length counter n is a second variable that must stay in lockstep with the first, and the day it does not is the day you get a corrupted filename.

The modern version deletes the entire apparatus:

! Modern style: the string sizes itself; no buffer, no counter.
character(:), allocatable :: name
character(len=32) :: buf
write(buf, '(a, i6.6, a)') 'heat_', step, '.vtk'
name = trim(buf)      ! name is now EXACTLY 'heat_000123.vtk', length 15
print '(a)', 'file: ' // name

There is no worst-case buffer to size (only the small scratch buf for the one formatted write, and even that could be avoided), no blanking, no position arithmetic, and — crucially — no companion length counter, because name knows its own length: len(name) is 15, and it will be whatever the next assignment makes it. The deferred-length string carries its length with it, which is the single thing FORTRAN 77 could not do and the reason its string code was such a chore.

🔧 Modern vs Legacy: the two eras of Fortran string building, side by side.

Task FORTRAN 77 (fixed-length) Modern Fortran (deferred-length)
Declare a "string" character(len=64) :: s (guess a size) character(:), allocatable :: s
Know its true length track it yourself in an integer n len(s) — it's carried with the value
Build up a value write into s(a:b) by hand, update n s = s // piece (reallocates to fit)
Pass it somewhere pass s(1:n), or ship the blanks pass s — no padding to leak
Right-size the result impossible — the buffer is fixed automatic — the string is its content

This is the theme of the whole book in one table: modern Fortran is a modern language. The old code is not wrong — much of it still runs, correctly, in production — but the modern tools remove an entire category of bookkeeping and the bugs that came with it.

📜 From History: why did FORTRAN 77 make strings so awkward? For the same reason it made everything static: memory. On the machines of the 1970s, allocating and reallocating storage at run time was a luxury the language deliberately avoided, so every object — arrays and strings alike — had a size fixed at compile time. Deferred-length strings (Fortran 2003) and automatic reallocation are the character-typed face of the same dynamic-memory revolution that gave arrays allocatable in Fortran 90. When you write character(:), allocatable, you are using a capability that simply did not exist for the language's first forty-six years — which is exactly why the "Fortran can't do strings" reputation formed, and exactly why it is now wrong. And it is one more reason legacy code is not a burden: the fixed-length buffers you find in an old code are not incompetence, they are an artifact of their era, and they modernize cleanly.


Project Checkpoint

Your heat solver will, from Chapter 26 onward, write one output file per timestep so you can watch the plate evolve in ParaView. Those files need names that are unique and that sort in time order — which means a zero-padded step number, exactly the heat_000123.vtk pattern this chapter has been circling. This checkpoint builds the small helper that produces them, using an internal-file write.

Add a function that turns a step number into a frame filename. It returns a deferred-length string, so the caller gets a name of exactly the right length with no trailing blanks to trim:

program checkpoint_frames
  implicit none
  integer :: k
  integer, parameter :: steps(4) = [0, 42, 123, 999999]

  do k = 1, size(steps)
    print '(a)', frame_name(steps(k))
  end do

contains

  function frame_name(step) result(name)
    integer, intent(in)       :: step
    character(:), allocatable :: name
    character(len=32)         :: buf
    write(buf, '(a, i6.6, a)') 'heat_', step, '.vtk'   ! format into a scratch buffer
    name = trim(buf)                                    ! deferred-length: exact size
  end function frame_name

end program checkpoint_frames
$ gfortran -std=f2018 -Wall project-checkpoint.f90 -o frames && ./frames
heat_000000.vtk
heat_000042.vtk
heat_000123.vtk
heat_999999.vtk

The i6.6 descriptor zero-pads each step to six digits, so step 0 becomes 000000, step 42 becomes 000042, and step 999999 fills the field exactly as 999999. Because frame_name's result is character(:), allocatable, the assignment name = trim(buf) sizes it to precisely fifteen characters — the caller never sees a padding blank, and never has to trim. That is the deferred-length payoff from §12.1 doing real work: a function can return a string whose length it computes at run time, which a fixed-length result could not.

Record two things in your heat-solver/ notes. First, the naming convention — heat_NNNNNN.vtk, six zero-padded digits — because Chapter 26 will call frame_name from the output routine and hand the result to the VTK writer, and ParaView will rely on the zero-padding to load the frames in order. Second, the ceiling: i6.6 holds up to 999,999 steps; if a production run needs more, widen the field (i8.8) before it silently fills a name with asterisks. Your solver now knows how to name its output — the last piece of plumbing before, in Chapter 26, it starts producing that output for real.


Summary

Modern Fortran handles text with three tools — self-sizing strings, a family of intrinsics, and internal files — that between them cover almost everything daily scientific code asks of strings.

Feature What it does Key rule
character(len=n) fixed-length string blank-padded on the right; len = declared length
character(:), allocatable deferred-length string sizes itself from assignment; len = exact content
character(len=*) assumed-length dummy a procedure takes the caller's length
len / len_trim length with / without trailing blanks len_trim is usually what you mean
trim drop trailing blanks trim(adjustl(s)) drops both ends
adjustl / adjustr left- / right-justify length preserved; blanks moved, not deleted
index(s, sub[, back]) position of sub, or 0 1-based; test > 0, never as a boolean
scan(s, set[, back]) first char in set, or 0 find a delimiter / a digit
verify(s, set[, back]) first char not in set, or 0 == 0 means "all characters are in set"
// concatenation trim fixed-length operands first, or splice blanks
s(i:j) substring 1-based, inclusive; a string like any other
write(s, fmt) / read(s, fmt) internal file number↔text with Chapter 7's edit descriptors

The three things to memorize. First, character(:), allocatable is a string that sizes itself — it is the allocatable idea applied to characters, and it is the default you should reach for. Second, trim before you concatenate or compare a fixed-length string, or its trailing blanks come along for the ride. Third, an internal file is just a read/write aimed at a string — it is how you convert between numbers and text, and it is how you build the filename heat_000123.vtk with write(buf, '(a,i6.6,a)').

Spaced Review

Retrieval practice on the two chapters this one leans on: variables and types (Chapter 3) and I/O (Chapter 7), whose edit descriptors internal files reuse wholesale. Answer before you open the details.

  1. (Ch. 7) What does the edit descriptor i6.6 produce for the integer 42, and why is the .6 part what makes output filenames sort correctly?

    Answer It produces '000042' — a field six characters wide, zero-padded to a minimum of six digits. The zero-padding gives every filename the same digit width, so lexical (alphabetical) sorting matches numeric order: heat_000042.vtk sorts before heat_000123.vtk. Without padding, heat_42.vtk and heat_123.vtk would sort with 123 before 42, scrambling a time series.

  2. (Ch. 7) What is the difference between the A edit descriptor and the list-directed * format when writing a character value?

    Answer A is a formatted descriptor that writes the string's characters exactly, with no extra spacing, giving you precise control (and you can width it, A10, to pad or truncate to a set field). The list-directed * format lets the processor choose the layout — it typically adds a leading blank and separates items with spaces — which is convenient for quick output but not for building an exact string. For assembling filenames and labels you use A (inside an internal write); for a fast debug print, *.

  3. (Ch. 3) What does selected_real_kind(15, 307) request, and why do we store its result in the dp parameter rather than sprinkling the number through the code?

    Answer It requests a real kind with at least 15 decimal digits of precision and an exponent range reaching at least 10±307 — i.e., IEEE double precision on essentially every machine. Storing it once as integer, parameter :: dp = selected_real_kind(15, 307) and writing real(dp) everywhere means the whole program's precision is defined in a single place: change that one line and every real follows, and the code stays portable across compilers that number their kinds differently.

  4. (Ch. 3) In integer arithmetic, what is 7 / 2, and what would you write instead to get 3.5?

    Answer 7 / 2 is 3 — integer division truncates toward zero, discarding the remainder, because both operands are integers. To get 3.5 at least one operand must be real: 7.0_dp / 2.0_dp (or real(7, dp) / 2). This is the classic integer-division trap from Chapter 3, and it is worth re-flagging here because internal reads that parse text into integers inherit the same arithmetic afterward.

  5. (Ch. 7) Reading the line '3 14 159' list-directed with read(line, *) a, b, c into three integers — what do a, b, and c become, and what plays the role of the separator?

    Answer a = 3, b = 14, c = 159. List-directed input treats runs of blanks (and commas) as separators between values, so the three space-separated fields map to the three variables in order. This is the same list-directed input from Chapter 7, now aimed at a string via an internal file rather than at a real file or the keyboard.

What's Next

You can now build strings, search them, and convert them to and from numbers — which means your programs can talk: read a configuration line, label an output, name a file. What they cannot yet do is cope gracefully when that text is wrong — a config line with a typo, a number that will not parse, a grid size that makes no physical sense. Chapter 13 turns to exactly that: error handling, debugging, and defensive programming. You will generalize the iostat mechanism you have used for I/O (and just now for internal reads) into a habit of checking every operation that can fail, meet error stop and the allocation stat=/errmsg= machinery, and wire up the compiler's debugging flags — -fcheck=all, -fbacktrace, -ffpe-trap — that turn a silent wrong answer into a loud, locatable one. Your solver will start validating its inputs and refusing, loudly, to run on a bad configuration. Text was how the program speaks; error handling is how it tells you when something has gone wrong.