ZetCode

Algol 68 scripts and compilation

last modified November 14, 2026

The interpreter has two ways to run a program. By default it parses the source, builds the tree, and walks it, which starts in milliseconds and needs no toolchain. With -O0 to -O3 it translates the tree to C, hands the file to the system C compiler, loads the resulting shared object, and runs that instead, which takes longer to start but executes loops several times faster.

This article runs a program both ways, turns a source file into a command with a shebang, splits a program across two files, follows the C compiler through the artefacts it leaves, reuses the compiled code, and measures the difference. It ends with the checks that compilation quietly removes.

Scripts with a shebang

A file whose first line names the interpreter can be executed directly. The line starts with #! and gives the path of a68g; /usr/bin/env finds it on the PATH rather than fixing one installation directory. The interpreter skips that line, so the same file also runs under a68g explicitly.

hello.a68
#!/usr/bin/env a68g

BEGIN
  print(("hello from a script", newline))
END

The execute bit is what makes the kernel run the file, and the shebang is what tells the kernel which program to start. Without the bit the shell reports a permission error, and without the shebang the kernel treats the file as a shell script and fails on BEGIN.

$ chmod +x hello.a68
$ ./hello.a68
hello from a script
$ a68g hello.a68
hello from a script

Splitting a program across files

A program of a few hundred lines is easier to keep in several files. The pragma PR read "lib.a68" PR inserts the contents of another file at that point, before the parser looks at the enclosing unit, so the reading file sees the declarations of the read file as if they were written in place.

lib.a68
PROC average = ([]INT values) REAL:
(
  INT sum := 0;
  FOR i FROM LWB values TO UPB values DO
    sum +:= values[i]
  OD;
  sum / (UPB values - LWB values + 1)
);

PROC largest = ([]INT values) INT:
(
  INT max := values[LWB values];
  FOR i FROM LWB values + 1 TO UPB values DO
    IF values[i] > max THEN max := values[i] FI
  OD;
  max
);
report.a68
PR read "lib.a68" PR

BEGIN
  []INT data = (4, 8, 15, 16, 23, 42);
  print(("count   ", whole(UPB data - LWB data + 1, 0), newline));
  print(("largest ", whole(largest(data), 0), newline));
  print(("average ", fixed(average(data), 0, 2), newline))
END

The path is relative to the working directory, not to the file that contains the pragma. Running the program from the directory that holds both files works; running it from elsewhere with an absolute path fails, because lib.a68 is then looked for in the current directory.

$ a68g report.a68
count   6
largest 42
average 18.00
$ cd /tmp
$ a68g /tmp/opencode/a68b/proj/report.a68
a68g: 1: error while opening source file "lib.a68" (no such file or directory) (detected at character "r").

Compiling through C

The -O2 option turns on compilation and passes the level to the C compiler. The interpreter writes a C file next to the source, compiles it to an object file, links a shared object with the same stem, loads it, and runs it. Only the shared object remains afterwards; the intermediate files are removed unless --keep is given.

report.a68
PR read "lib.a68" PR

BEGIN
  []INT data = (4, 8, 15, 16, 23, 42);
  print(("count   ", whole(UPB data - LWB data + 1, 0), newline));
  print(("largest ", whole(largest(data), 0), newline));
  print(("average ", fixed(average(data), 0, 2), newline))
END

The output is the same as the interpreted run, because the compiled program computes the same values. The generated C file is readable and shows how each Algol 68 unit became a C function call, which makes --keep useful when a program behaves differently under compilation.

$ a68g -O2 report.a68
count   6
largest 42
average 18.00
$ ls report.*
report.a68  report.so
$ a68g -O2 --keep report.a68
$ ls report.*
report.a68  report.c  report.so

--verbose prints the front-end phases and then the exact commands that were run, so the C compiler and the linker are visible with their flags. The two lines below are the whole back end for this program.

$ a68g -O2 --verbose report.a68
...
a68g: optimiser (code compiler)
gcc -I. -I/usr/include -O2 -g -fPIC -c -o "report.o" "report.c"
ld -export-dynamic -shared -o "report.so" "report.o"
a68g: dynamic linker
a68g: genie
count   6
largest 42
average 18.00

Reusing the compiled code

The shared object is not a standalone program: it is a library that the interpreter loads. Running it directly does not work, because the C file has no main and expects the interpreter's runtime. Its advantage is that it can be loaded again without the C compiler, which is what --rerun does.

bench.a68
BEGIN
  INT total := 0;
  FOR i TO 20000000 DO
    total +:= i MOD 7
  OD;
  print((whole(total, 0), newline))
END

The first compiled run pays for gcc and ld; the second loads the existing shared object and skips both. If the shared object is missing, --rerun stops with an error rather than falling back to the interpreter.

$ a68g -O2 bench.a68
60000003
$ a68g --rerun bench.a68
60000003
$ rm bench.so
$ a68g --rerun bench.a68
a68g: exiting: source/a68g.c: 989: cannot stat, ./bench.so (no such file or directory)

Measuring the difference

The loop in bench.a68 runs twenty million additions and remainders, which is long enough to measure and short enough to wait for. The times below were taken on one machine with /usr/bin/time, three runs per mode; they include the start-up cost, and the compiled times include the C compiler.

bench.a68
BEGIN
  INT total := 0;
  FOR i TO 20000000 DO
    total +:= i MOD 7
  OD;
  print((whole(total, 0), newline))
END

The interpreted run takes about seven times as long as the compiled one. Even -O0, which asks the C compiler for no optimisation, is more than twice as fast as the tree walker, because the C compiler still turns each unit into machine code. -O2 and -O3 are indistinguishable here: the loop is already simple and the optimiser has nothing left to remove.

$ /usr/bin/time -f "%e s" a68g bench.a68
60000003
1.26 s
$ /usr/bin/time -f "%e s" a68g -O0 bench.a68
60000003
0.47 s
$ /usr/bin/time -f "%e s" a68g -O1 bench.a68
60000003
0.22 s
$ /usr/bin/time -f "%e s" a68g -O2 bench.a68
60000003
0.18 s
$ /usr/bin/time -f "%e s" a68g -O3 bench.a68
60000003
0.18 s

What compilation removes

The speed comes at a price that is easy to miss, because the interpreter does not announce it. The compiled program does not check row bounds and does not check that a variable was initialised. An out-of-bounds read returns whatever the memory holds, and the program continues as if nothing happened.

oob.a68
BEGIN
  []INT row = (1, 2, 3);
  print((row[5], newline))
END

Interpreted, the same file stops with "index out of bounds" and status 1. Compiled, it prints whatever the memory beyond the row holds and returns success; the value changes from run to run and from one optimisation level to the next. The check is not moved or deferred: it is absent from the generated code.

$ a68g oob.a68
3       print((row[5], newline))
                   1            
a68g: runtime error: 1: index out of bounds (detected in [] "SIMPLOUT" collateral-clause starting at "(" in this line).
$ echo $?
1
$ a68g -O2 oob.a68
-583554550
$ echo $?
0

Division by zero loses its message as well. The compiled program divides with the hardware instruction, so the process ends with a floating point exception and the shell reports signal 8, which appears as status 136. Assertions are the exception to the rule: ASSERT is compiled in and still stops the program with "false assertion".

dz.a68
BEGIN
  INT z := 0;
  print((10 OVER z, newline))
END

The practical rule is to develop and test with the interpreter, where every check runs, and to compile only code that has passed those tests. A compiled program should also be given a time limit when it processes input that the author does not control, because a wrong index no longer stops it.

$ a68g dz.a68
3       print((10 OVER z, newline))
                  1                
a68g: runtime error: 1: attempt at INT division by zero (numerical result out of range) (detected in [] "SIMPLOUT" collateral-clause starting at "(" in this line).
$ a68g -O2 dz.a68
Floating point exception (core dumped)
$ echo $?
136

What compilation does not change

The language and the output are the same in both modes. The prelude is still there, formatted transput still rounds the same way, the garbage collector still runs, and the shared object is tied to the machine and the C library that produced it. Compiling is a way to run a program faster, not a way to package it for another computer.

The debugging article shows the interpreter options that help when the compiled and interpreted runs disagree, and the unit testing article shows the harness that should pass before a program is compiled.

Source

The Algol 68 Genie project

Revised Report on the Algorithmic Language Algol 68

In this article we made a source file executable with a shebang, split a program with PR read, compiled it through C, inspected the artefacts and the compiler commands, reused the shared object with --rerun, measured a sevenfold speed-up, and found the runtime checks that compilation removes.

Author

My name is Jan Bodnar, and I am a passionate programmer with extensive programming experience. I have been writing programming articles since 2007. To date, I have authored over 1,400 articles and 8 e-books. I possess more than ten years of experience in teaching programming.

List all Algol 68 tutorials.