Intermediate

Functions in Modula-2

Learn procedures and function procedures in Modula-2 - value and VAR parameters, scope, nested procedures, recursion, and procedure types with Docker-ready examples

Modula-2 is an imperative, procedural, and modular language, and the procedure is its fundamental unit of abstraction. Wirth made a deliberate distinction that many later languages blurred: a proper procedure performs an action and returns nothing, while a function procedure computes and returns a value. Both are declared with the same PROCEDURE keyword — the presence of a return type is what separates them.

Because Modula-2 is strongly and statically typed, every parameter and every return value has a declared type that the compiler checks at every call site. The language also gives you precise control over how arguments are passed: value parameters receive a private copy, while VAR parameters are passed by reference so the procedure can modify the caller’s variable. This explicit choice — invisible in many dynamic languages — is central to writing correct Modula-2 code.

In this tutorial you will learn how to declare and call procedures, the difference between value and VAR parameters, how variable scope works (including Modula-2’s distinctive nested procedures), how to write recursive procedures, and how procedures themselves can be stored in variables and passed as arguments through procedure types.

Proper Procedures and Function Procedures

A function procedure declares a return type after the parameter list and uses RETURN to produce its result. A proper procedure has no return type and simply executes statements.

Create a file named functions.mod:

 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
MODULE Functions;

FROM StrIO IMPORT WriteString, WriteLn;
FROM NumberIO IMPORT WriteInt;

(* A function procedure: declares a return type and uses RETURN *)
PROCEDURE Square(n: INTEGER): INTEGER;
BEGIN
  RETURN n * n
END Square;

(* A proper procedure: performs an action, returns nothing *)
PROCEDURE Greet(name: ARRAY OF CHAR);
BEGIN
  WriteString("Hello, ");
  WriteString(name);
  WriteString("!");
  WriteLn
END Greet;

BEGIN
  Greet("Modula-2");
  WriteString("5 squared is ");
  WriteInt(Square(5), 1);    (* Square returns a value used in an expression *)
  WriteLn
END Functions.

Notice that:

  • The procedure name is repeated in the END clause (END Square;) — just like modules, this catches copy-paste errors.
  • Square is called inside an expression because it yields a value; Greet is called as a statement because it does not.
  • name: ARRAY OF CHAR is an open array parameter — it accepts a character array of any length, so the same procedure handles any string literal.

Value Parameters vs VAR Parameters

By default, parameters are passed by value: the procedure receives a copy, and changes to it do not affect the caller. Prefix a parameter with VAR to pass it by reference, allowing the procedure to modify the original variable.

Create a file named parameters.mod:

 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
MODULE Parameters;

FROM StrIO IMPORT WriteString, WriteLn;
FROM NumberIO IMPORT WriteInt;

(* Value parameter: x is a copy, the caller's variable is untouched *)
PROCEDURE TryToChange(x: INTEGER);
BEGIN
  x := 999
END TryToChange;

(* VAR parameters: a and b are passed by reference and can be modified *)
PROCEDURE Swap(VAR a, b: INTEGER);
VAR
  temp: INTEGER;
BEGIN
  temp := a;
  a := b;
  b := temp
END Swap;

VAR
  p, q: INTEGER;

BEGIN
  p := 10;
  q := 20;

  TryToChange(p);
  WriteString("After TryToChange, p = ");
  WriteInt(p, 1);            (* Still 10 - the copy was changed, not p *)
  WriteLn;

  Swap(p, q);
  WriteString("After Swap, p = ");
  WriteInt(p, 1);
  WriteString(", q = ");
  WriteInt(q, 1);
  WriteLn
END Parameters.

The Swap procedure is impossible to write with value parameters alone — without VAR, it would only shuffle copies. Because Modula-2 forces you to mark by-reference parameters explicitly, a reader can tell at a glance which arguments a procedure might modify.

Variable Scope and Nested Procedures

Variables declared with VAR at the module level are global to every procedure in the module. Variables declared inside a procedure are local — they exist only while the procedure runs. Modula-2 also allows procedures to be nested inside other procedures, and an inner procedure can see the local variables of the procedure that encloses it.

Create a file named scope.mod:

 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
MODULE Scope;

FROM StrIO IMPORT WriteString, WriteLn;
FROM NumberIO IMPORT WriteInt;

VAR
  counter: INTEGER;          (* Module-level (global) variable *)

PROCEDURE Increment;
VAR
  step: INTEGER;             (* Local: created and destroyed each call *)
BEGIN
  step := 1;
  counter := counter + step  (* Can read and write the global counter *)
END Increment;

(* Outer contains a nested procedure Inner *)
PROCEDURE Outer;
VAR
  message: ARRAY [0..20] OF CHAR;

  PROCEDURE Inner;
  BEGIN
    WriteString(message);    (* Inner sees Outer's local variable *)
    WriteLn
  END Inner;

BEGIN
  message := "Called from Inner";
  Inner                       (* Inner is only visible inside Outer *)
END Outer;

BEGIN
  counter := 0;
  Increment;
  Increment;
  Increment;
  WriteString("counter = ");
  WriteInt(counter, 1);
  WriteLn;
  Outer
END Scope.

Inner is invisible outside of Outer — nesting lets you hide helper procedures exactly where they are used, keeping the module’s namespace clean. This is the same information-hiding instinct that drives Modula-2’s module system, applied at the procedure level.

Recursion

A function procedure may call itself. Recursion expresses naturally definitions like factorial and the Fibonacci sequence. Here we use CARDINAL (unsigned integers) since these values are non-negative.

Create a file named recursion.mod:

 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
MODULE Recursion;

FROM StrIO IMPORT WriteString, WriteLn;
FROM NumberIO IMPORT WriteCard;

(* Factorial: n! = n * (n-1)! with base case 0! = 1! = 1 *)
PROCEDURE Factorial(n: CARDINAL): CARDINAL;
BEGIN
  IF n <= 1 THEN
    RETURN 1
  ELSE
    RETURN n * Factorial(n - 1)
  END
END Factorial;

(* Fibonacci: each value is the sum of the two before it *)
PROCEDURE Fib(n: CARDINAL): CARDINAL;
BEGIN
  IF n < 2 THEN
    RETURN n
  ELSE
    RETURN Fib(n - 1) + Fib(n - 2)
  END
END Fib;

VAR
  i: CARDINAL;

BEGIN
  WriteString("5! = ");
  WriteCard(Factorial(5), 1);
  WriteLn;

  WriteString("Fibonacci: ");
  FOR i := 0 TO 9 DO
    WriteCard(Fib(i), 1);
    WriteString(" ")
  END;
  WriteLn
END Recursion.

Each recursive call must move toward the base case (n <= 1 or n < 2); otherwise the recursion never terminates. Modula-2 places no special restriction on recursion — a function procedure can call itself directly, as shown, or indirectly through other procedures.

Procedure Types: Procedures as Values

Modula-2 treats procedures as first-class enough to store in variables and pass as arguments. You declare a procedure type describing the parameter and return types, then any matching procedure can be assigned to a variable of that type. Only procedures declared at the outermost module level (not nested) may be used this way.

Create a file named higherorder.mod:

 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
MODULE HigherOrder;

FROM StrIO IMPORT WriteString, WriteLn;
FROM NumberIO IMPORT WriteInt;

(* A procedure type: takes an INTEGER, returns an INTEGER *)
TYPE
  IntFunc = PROCEDURE (INTEGER): INTEGER;

PROCEDURE Double(n: INTEGER): INTEGER;
BEGIN
  RETURN n * 2
END Double;

PROCEDURE Negate(n: INTEGER): INTEGER;
BEGIN
  RETURN -n
END Negate;

(* Apply receives a procedure as a parameter and calls it *)
PROCEDURE Apply(f: IntFunc; value: INTEGER): INTEGER;
BEGIN
  RETURN f(value)
END Apply;

VAR
  op: IntFunc;

BEGIN
  WriteString("Apply(Double, 7) = ");
  WriteInt(Apply(Double, 7), 1);
  WriteLn;

  op := Negate;              (* Store a procedure in a variable *)
  WriteString("op(7) = ");
  WriteInt(op(7), 1);        (* Call through the variable *)
  WriteLn
END HigherOrder.

The Apply procedure does not know in advance which operation it will run — Double, Negate, or any other procedure matching IntFunc. Procedure types give Modula-2 a form of higher-order programming while keeping the compiler’s full type checking: assigning a procedure with the wrong signature to op is a compile-time error.

Running with Docker

Each example is a standalone program module. Pull the image once, then compile and run each file with gm2.

1
2
3
4
5
6
7
8
9
# Pull the Modula-2 image (GNU Modula-2 / gm2)
docker pull codearchaeology/modula-2:latest

# Compile and run each example
docker run --rm -v $(pwd):/app -w /app codearchaeology/modula-2:latest sh -c 'gm2 -o functions functions.mod && ./functions'
docker run --rm -v $(pwd):/app -w /app codearchaeology/modula-2:latest sh -c 'gm2 -o parameters parameters.mod && ./parameters'
docker run --rm -v $(pwd):/app -w /app codearchaeology/modula-2:latest sh -c 'gm2 -o scope scope.mod && ./scope'
docker run --rm -v $(pwd):/app -w /app codearchaeology/modula-2:latest sh -c 'gm2 -o recursion recursion.mod && ./recursion'
docker run --rm -v $(pwd):/app -w /app codearchaeology/modula-2:latest sh -c 'gm2 -o higherorder higherorder.mod && ./higherorder'

Expected Output

Running functions.mod:

Hello, Modula-2!
5 squared is 25

Running parameters.mod:

After TryToChange, p = 10
After Swap, p = 20, q = 10

Running scope.mod:

counter = 3
Called from Inner

Running recursion.mod:

5! = 120
Fibonacci: 0 1 1 2 3 5 8 13 21 34 

Running higherorder.mod:

Apply(Double, 7) = 14
op(7) = -7

Key Concepts

  • Two kinds of procedures — a function procedure declares a return type and is used in expressions via RETURN; a proper procedure has no return type and is called as a statement. Both use the PROCEDURE keyword.
  • Value parameters are copies — modifying a value parameter never affects the caller’s variable.
  • VAR parameters pass by reference — prefix a parameter with VAR so the procedure can modify the caller’s variable; this is required for operations like Swap.
  • Scope is lexical — module-level VAR declarations are global to all procedures, while procedure-local variables live only for the duration of a call.
  • Nested procedures hide helpers — a procedure declared inside another is visible only within its parent and can read the parent’s local variables.
  • Recursion is fully supported — a function procedure may call itself, provided each call advances toward a base case.
  • Procedure types enable higher-order code — declare a PROCEDURE (...) type, then store matching top-level procedures in variables or pass them as arguments, all with compile-time type checking.
  • The END clause repeats the nameEND Square; mirrors END ModuleName. and helps the compiler catch structural mistakes.

Running Today

All examples can be run using Docker:

docker pull codearchaeology/modula-2:latest
Last updated:

Comments

Loading comments...

Leave a Comment

2000 characters remaining