Scientific programming in Modula-9 — first steps

↖ contents

17 — Living in the real world

Every program in this tutorial so far has been a closed world: it computed something and printed it, and the only thing that crossed its boundary was the answer. Real programs are not like that. They are handed parameters by whoever ran them, they drive other programs that already do a job well, and they read back what those programs said. A data pipeline is exactly this shape — one tool's output is the next tool's input — and most working software is a data pipeline wearing a coat.

This chapter builds a small one. It counts how often each word appears in a passage, the way the classic shell line does — sort | uniq -c | sort — but from M9, and without a shell: each stage is its own program, run through System.Exec, fed the previous stage's output on its standard input. It reads an optional --top=N option, checks that every stage *worked*, and parses the columns that come back.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
MODULE C17Pipe ;

(* Chapter 17: living in the real world.

   A program that only ever talks to itself is a demo.  A useful one
   takes PARAMETERS from whoever ran it and DRIVES OTHER PROGRAMS,
   then reads back what they said.  This one counts word frequencies
   the way a shell line would -- sort, then uniq -c, then sort again
   -- but from M9, and without a shell: each stage is its OWN program,
   run through System.Exec, fed the previous stage's output on its
   stdin and INSISTED to have succeeded.

   Three things this chapter is really about:

   * A stage's output comes back as a VALUE, and so does its status.
     System.Exec answers a Result -- the exit status, everything the
     program wrote to stdout, and everything it wrote to stderr -- so
     the pipe between two tools is an M9 string, not a temp file, and
     a stage that FAILED is a status you look at rather than a thing
     you hope did not happen.  A shell one-liner hides the middle of
     the pipe; here every stage is checked.

   * The environment is a boundary, and boundaries lie unless they
     are pinned.  Sort order depends on the locale, so the SAME
     pipeline gives a different answer on a French machine -- unless
     LC_ALL=C nails it to bytes.  Exec sets it as an environment
     OVERRIDE, merged over the inherited environment, so PATH still
     finds the program.  It is the process-level echo of why Fmt owns
     its float printing instead of borrowing a locale-dependent
     printf.

   * A parameter is a DECISION, not an accident.  The count of rows
     to show is a NAMED option, --top=N, the convention System.Value
     defines -- never a bare number that could be mistaken for a
     filename -- and a value that is not a count is reported before
     the default is used.

   With no arguments it shows the top DefaultTop; run it as
   `./c17pipe --top=3` to ask for three.                             *)

IMPORT System ;
IMPORT DynStr ;
IMPORT Io ;
IMPORT Fmt ;

CONST
  DefaultTop = 5 ;

(* the text to count, fixed so the whole run is reproducible.  A
   CONST cannot be a `+` expression, so the passage is a procedure
   that composes its literals into HEAP and answers the whole. *)
PROCEDURE Passage () : STR =
BEGIN
  RETURN
    'the program reads the data and the program checks the data '
    + 'a program that checks its data and checks its result is a '
    + 'program you can trust the data does not lie and neither '
    + 'does the result the program reads the data the program '
    + 'writes the result'
END Passage ;

PROCEDURE IsLetter (c: CHAR) : BOOL =
BEGIN
  RETURN ((c >= 'a') AND (c <= 'z')) OR ((c >= 'A') AND (c <= 'Z'))
END IsLetter ;

PROCEDURE Lower (c: CHAR) : CHAR RAISES ValueRange =
BEGIN
  IF (c >= 'A') AND (c <= 'Z') THEN
    RETURN CHR (ORD (c) + 32)
  END ;
  RETURN c
END Lower ;

(* the passage as one lowercase word per line -- the input a
   sort | uniq pipeline wants.  Anything that is not a letter is a
   word break. *)
PROCEDURE WordsOf (RO text: STR) : STR
  RAISES ValueRange, IndexError =
VAR
  pool : POOL ;
  d : PTR DynStr.DString IN pool ;
  i : I64 ;
  inWord : BOOL ;
BEGIN
  d := DynStr.New (pool) ;
  inWord := FALSE ;
  FOR i := 0 TO LEN (text) - 1 DO
    IF IsLetter (text[i]) THEN
      DynStr.AppendChar (d, Lower (text[i])) ;
      inWord := TRUE
    ELSIF inWord THEN
      DynStr.AppendChar (d, 0AC) ;   (* end this word *)
      inWord := FALSE
    END
  END ;
  IF inWord THEN DynStr.AppendChar (d, 0AC) END ;
  RETURN DynStr.View (d)
END WordsOf ;

(* a non-negative integer that is the WHOLE of s, or -1 if it is
   not one *)
PROCEDURE ParseInt (RO s: STR) : I64 RAISES IndexError =
VAR
  i, v : I64 ;
BEGIN
  IF LEN (s) = 0 THEN RETURN 0 - 1 END ;
  v := 0 ;
  FOR i := 0 TO LEN (s) - 1 DO
    IF (s[i] < '0') OR (s[i] > '9') THEN RETURN 0 - 1 END ;
    v := v * 10 + (ORD (s[i]) - ORD ('0'))
  END ;
  RETURN v
END ParseInt ;

(* how many rows to show: --top=N, or DefaultTop.  System.Value reads
   the text after --top= in the options, or answers the default we
   name here.  A number the parser rejects is reported before the
   default is used, so a mistyped option is never mistaken for a
   deliberate omission. *)
PROCEDURE TopWanted () : I64 RAISES ValueRange, IndexError =
VAR
  s : STR ;
  n : I64 ;
BEGIN
  s := System.Value ('--top', '') ;
  IF LEN (s) = 0 THEN RETURN DefaultTop END ;
  n := ParseInt (s) ;
  IF n >= 0 THEN RETURN n END ;
  Io.ErrLine ('--top is not a count; showing ' + Fmt.I64Str (DefaultTop)) ;
  RETURN DefaultTop
END TopWanted ;

(* run one pipeline stage: prog with args, fed `input` on its stdin,
   pinned to LC_ALL=C so the byte ordering does not depend on the
   machine's locale, and INSISTED to have succeeded.  A stage that
   exits nonzero stops the whole run, naming its status and whatever
   it wrote to stderr -- because a pipeline that RAN is not a pipeline
   that WORKED. *)
PROCEDURE Stage (VAR pool: POOL ; RO prog: STR ; RO args: SLICE OF STR ;
                 RO input: STR) : STR
  RAISES ValueRange, Io.IOError =
VAR
  env : SLICE OF STR ;
  r : System.Result ;
BEGIN
  env := NEW (pool, STR, 1) ;
  env[0] := 'LC_ALL=C' ;
  r := System.Exec (pool, prog, args, input, env) ;
  IF r.status # 0 THEN
    Io.ErrLine (prog + ' exited with status ' + Fmt.I64Str (r.status)) ;
    IF LEN (r.err) > 0 THEN Io.ErrLine (r.err) END ;
    Io.Halt (1)
  END ;
  RETURN r.out
END Stage ;

(* uniq -c prints "   COUNT word", the count right-justified in a
   field of blanks.  Parsing a tool's REAL output -- padded columns
   and all -- is most of what living in the real world is.  Show the
   first `top` rows as a ranked table. *)
PROCEDURE Report (RO counts: STR ; top: I64)
  RAISES ValueRange, IndexError =
VAR
  i, j, shown, count : I64 ;
  word : STR ;
BEGIN
  Io.WriteLine ('the ' + Fmt.I64Str (top)
                + ' most frequent words in the passage:') ;
  i := 0 ;
  shown := 0 ;
  WHILE (i < LEN (counts)) AND (shown < top) DO
    WHILE (i < LEN (counts)) AND (counts[i] = ' ') DO i := i + 1 END ;
    count := 0 ;
    WHILE (i < LEN (counts)) AND (counts[i] >= '0')
          AND (counts[i] <= '9') DO
      count := count * 10 + (ORD (counts[i]) - ORD ('0')) ;
      i := i + 1
    END ;
    IF (i < LEN (counts)) AND (counts[i] = ' ') THEN i := i + 1 END ;
    j := i ;
    WHILE (j < LEN (counts)) AND (counts[j] # 0AC) DO j := j + 1 END ;
    word := SLICE (counts, i, j - i) ;
    IF LEN (word) > 0 THEN
      shown := shown + 1 ;
      Io.WriteLine ('  ' + Fmt.I64Str (shown) + '. ' + word
                    + '  (' + Fmt.I64Str (count) + ')')
    END ;
    i := j ;
    IF (i < LEN (counts)) AND (counts[i] = 0AC) THEN i := i + 1 END
  END
END Report ;

VAR
  pool : POOL ;
  top : I64 ;
  none, uniqArgs, sortArgs : SLICE OF STR ;
  words, sorted, counted, ranked : STR ;

BEGIN
  BEGIN
    top := TopWanted () ;

    (* the passage as one lowercase word per line, built in memory --
       the input the first stage reads on its stdin *)
    words := WordsOf (Passage ()) ;

    none := NEW (pool, STR, 0) ;
    uniqArgs := NEW (pool, STR, 1) ;
    uniqArgs[0] := '-c' ;
    sortArgs := NEW (pool, STR, 2) ;
    sortArgs[0] := '-k1,1nr' ;
    sortArgs[1] := '-k2,2' ;

    (* three stages, each its own program, each checked.  The output
       of one is the stdin of the next -- an M9 string, not a temp
       file.  The second sort ranks by count (numeric, descending)
       then by the word, so ties have a defined order and every run
       is identical down to the byte. *)
    sorted := Stage (pool, 'sort', none, words) ;
    counted := Stage (pool, 'uniq', uniqArgs, sorted) ;
    ranked := Stage (pool, 'sort', sortArgs, counted) ;

    Report (ranked, top)
  EXCEPT
  | Io.IOError (p) :
      Io.ErrLine ('could not start a pipeline stage:') ;
      Io.ErrLine (p) ;
      Io.Halt (1)
  | ValueRange :
      Io.ErrLine ('a value was out of range') ; Io.Halt (1)
  | IndexError :
      Io.ErrLine ('an index was out of range') ; Io.Halt (1)
  END
END C17Pipe.
C17Pipe.m9

With no arguments it shows the five most frequent words; the counts and their order are fixed, because the passage is fixed and the pipeline is pinned (more on that below).

expected output:

the 5 most frequent words in the passage:
  1. the  (10)
  2. program  (6)
  3. data  (5)
  4. and  (3)
  5. checks  (3)

Run it with a number — ./c17pipe --top=3 — and it shows three. Run it with a --top= that is not a number and it says so and falls back to the default, rather than guessing.

The output is a value, and so is the status

System.Exec runs a program directly — no shell — and answers a Result: the exit status, everything the program wrote to out, and everything it wrote to err. So a stage's output is an ordinary M9 string, and the pipe between two tools is that string handed to the next stage as its input:

sorted  := Stage (pool, 'sort', none, words) ;
counted := Stage (pool, 'uniq', uniqArgs, sorted) ;
ranked  := Stage (pool, 'sort', sortArgs, counted) ;

There is no counts.txt on disk; sorted and counted are values, and you can see the handoff in the program instead of inferring it from a shell line.

And every stage's status is checked, inside Stage:

r := System.Exec (pool, prog, args, input, env) ;
IF r.status # 0 THEN
  Io.ErrLine (prog + ' exited with status ' + Fmt.I64Str (r.status)) ;
  IF LEN (r.err) > 0 THEN Io.ErrLine (r.err) END ;
  Io.Halt (1)
END ;

A pipeline that *ran* is not a pipeline that *worked*. sort fails if the count of key fields is wrong; uniq fails on a bad option; a typo in a program name fails to start at all. Reading a failed stage's output would parse whatever it managed to write before it died, and report a confident wrong answer. Checking the status is the same discipline this language applies to a slice index or a conversion, now at the process boundary — and because each stage is run *as itself*, its status and its stderr are separate values you can look at.

That is the difference from running the whole line through the shell. M9 could hand sort | uniq -c | sort to Io.Run as one string, and it would work — but Io.Run answers one status, the shell's, which is the *last* stage's. A sort that failed in the middle of the pipe is invisible if the final sort succeeds, because the shell throws away the statuses in between. Running each stage as its own program is what makes every failure a value rather than a thing you hope did not happen.

A program that never finishes

System.Exec waits as long as the stage takes, which is right for a pipeline: sort on a large file is slow, not stuck, and no number you could write down would tell the two apart. But when the program is one *you did not write* — a cell a reader typed, a tool that may hang on a network — waiting for ever is a decision too, and usually the wrong one. System.ExecWithin is Exec with a deadline:

r := System.ExecWithin (pool, prog, args, input, env, 10.0) ;
IF r.stopped THEN
  Io.ErrLine (prog + ' was still running after ten seconds') ;
  Io.ErrLine (r.out)            (* what it had printed by then *)
END ;

Three things are worth knowing about it. The deadline covers the whole run, not just the program's exit: a stage that has finished while something *it* started still holds its output is the case that otherwise waits for ever. When the time runs out, the program and everything it started are stopped together. And a stopped run still reports — out and err hold what was written before the deadline, so you can say what it was doing when it was stopped instead of showing a blank page.

The tutorial you may be reading this in uses it: on Windows it runs your cells with a limit, because a cell that loops for ever would otherwise take the whole tutorial down with it.

The environment is a boundary, and boundaries lie unless you pin them

Look at how Stage builds its environment:

env := NEW (pool, STR, 1) ;
env[0] := 'LC_ALL=C' ;
r := System.Exec (pool, prog, args, input, env) ;

Sorting is locale-dependent: on a machine set to a French or Swedish locale, sort orders letters by that locale's rules, so the *same pipeline over the same data* produces a different order. A program whose output depends on an environment variable nobody set on purpose is a program that will disagree with itself between two machines and waste an afternoon.

LC_ALL=C nails the ordering to raw byte order, everywhere. The env argument is a list of NAME=VALUE overrides, merged over the environment the program already has — so this one pins LC_ALL and leaves PATH alone, which is what lets System.Exec find sort by its bare name in the first place. Replacing the whole environment would have thrown PATH away with it.

This is the same reason chapter 2's Fmt owns its float printing instead of borrowing C's printf: printf is locale-dependent too, and would write 3,14 where you meant 3.14 in half the world. The rule is one rule — *a boundary that can lie must be pinned* — and it applies to the process environment exactly as it applies to the numeric one.

The final sort is pinned in another way: -k1,1nr sorts by the first field (the count) numerically and in reverse, and -k2,2 adds the word as a tiebreaker. Without that second key, two words with the same count could come back in either order, and the output would be stable only by luck. With it, every run is identical down to the byte — which is what lets this chapter's expected output be an equality, not an approximation.

A parameter is a decision

The count of rows to show comes in as a named option, and System.Value reads it:

s := System.Value ('--top', '') ;
IF LEN (s) = 0 THEN RETURN DefaultTop END ;
n := ParseInt (s) ;
IF n >= 0 THEN RETURN n END ;
Io.ErrLine ('--top is not a count; showing ' + Fmt.I64Str (DefaultTop)) ;
RETURN DefaultTop

System.Value ('--top', '') reads the text after --top= in the options, or answers the default you name — here the empty string, which TopWanted then reads as "no option given" and turns into DefaultTop. The option is --top=3, attached with =, never a bare 3 sitting on the command line where nothing could say whether it was a count or a filename. That is System's whole argument convention: an option begins with -, a value is attached with =, and a bare -- ends the options. It is small enough to state in full, which is the point.

And a --top= whose value is not a count is reported *before* the default is used, so a mistyped option is never silently mistaken for a deliberate omission. "No option" is a default the program *decided*, not one it drifted into.

Parsing what a tool actually prints

uniq -c does not print clean two-column data; it prints the count right-justified in a field of blanks, then a space, then the word — a format that survives the final sort, so the first lines of the ranked output look like:

     10 the
      6 program
      5 data

So Report skips the leading blanks, reads the digits as the count, steps over the one separating space, and takes the rest of the line as the word. Parsing a real tool's real output — padding, separators and all — instead of wishing it were tidier is most of what living in the real world is. The bytes are what the tool sends; the program adapts to them, not the other way around.

The pipeline is a program you can read

words, sorted, counted and ranked are M9 strings, and the three Stage calls are the pipe. What a shell hides inside a single line — which program runs, what it is fed, whether it succeeded, what it complained about — is here four named values and three checked calls, in the open where a reader can see them.

That visibility is the whole idea, and it is where this tutorial started: M9 is built so that the person reading the program — and the data crossing its edges — can *see* what happened, and so that the compiler refuses the ways a boundary might quietly lie. A real program lives among other programs, arguments and locales, all of which will hand it something unexpected eventually. The work of the last seventeen chapters is that when they do, the program says so, at the line where it happened, instead of computing a confident wrong answer and printing it as if it were true.

← Previous: threads for computation, and what the cores actually give you · Next: evaluating a model →