Subprograms

Subprograms are an example of process abstraction:

This allows the programmer to hide details of the computation sequence, and simply refer to it by some logical identifier - used properly this can substantially readability and maintainability.

Basic definitions and characteristics

Initially, we make the following assumptions and definitions:

Functions and procedures

The distinction is sometimes made between subprograms which are functions, and those which are procedures.

In the strictest sense, functions should compute and return a value and should not have any observable side effects, while procedures may produce side effects by acting on either non-local variables or on parameters which allow the transfer of data to the caller (e.g. reference parameters).

Parameters

Information passing between caller and callee can be handled in two ways:

Using variables to communicate between routines has several problems:

As a result, the more accepted method for inter-routine communication is by passing parameters.

There are two methods for binding the actual parameters in a call to the formal parameters of a subroutine: keyword and positional.

Positional parameters match the actual parameters to the formal parameters in the order they are listed: i.e. the first actual parameter is matched to the first formal parameter, the second to the second, etc.

Keyword parameters require the programmer to provide, in the function call, both the actual parameter and the name of the formal parameter it is to be matched to.

The advantage of this system is that the programmer doesn't have to remember the order of parameters, the disadvantage is that the programmer does have to know the names of the formal parameters in the called routine.

Parameter numbers and types: as a language designer you must chose whether the types and number of actual parameters will be checked against the number and types of the formal parameters.

Pascal, Java, Fortran 90, Ada etc require type checking of actual vs formal parameters, while Fortran 77 and the original version of C do not.

In ANSI C the programmer can vary the definition format of a subprogram to enable/disable type checking of parameters:

// foo without type checking       // foo with type checking
int foo(x, y)                      int foo(double x, double y)
double x, y;                       {
{                                     ...
  ...                              }
}

In C++ type checking is carried out, with the following exception:

While this improves flexibility it clearly detracts from error checking.

Default values: the designer must also choose whether or not default values can be supplied to parameters.

For example, a valid C++ function with default values is

float calculate_taxes(float income = 0.0, float taxrate = 0.25)
{
   return(income * taxrate);
}
The function would have the following results:
cout << calculate_taxes();  // prints out 0
cout << calculate_taxes(100);  // prints out 25
cout << calculate_taxes(100,0.5);  // prints out 50

Return types: deciding which types of values can be returned by a function is another design issue.

Parameter passing methods: there are five main passing conventions:

Subprograms as parameters: it is sometimes useful to pass functions or procedures as parameters to other subprograms, which may then invoke them.

This introduces a number of design and implementation issues:

Local variables

Variables local to a subprogram are either static or stack dynamic.

Static local variables are shared across all invokations of the subprogram, while stack dynamic local variables are only accessible within the current invokation.

The main disadvantage with stack dynamic locals is a loss in run time efficiency, due to two factors:

A secondary disadvantage is that the locals cannot be used to communicate information across subprogram invokations.

However, in general stack dynamic locals are preferred over static locals because of the flexibility they provide: permitting nested and recursive function calls.


Polymorphic subprograms

Polymorphic subprograms can take parameters of different types on different activations.

This is typically supported through either overloaded subprograms or generic subprograms.

Overloaded subprograms:

In some languages (including Ada, Java, and C++) it is permissible to declare multiple routines with the same name as long as their protocols differ (i.e. they require different parameter/return types).

The correct subroutine body is identified based on the types of the passed parameters, and an appropriate call is invoked.

This is referred to as overloading (much as with operator overloading).

Note that readability generally suffers if radically different functionality is provided by the different functions associated with overloaded name.

Generic subprograms:

Generic subprograms are another method of specifying multiple versions of a program unit to handle parameters of different data types.

In C++ these are referred to as template functions, for example:

// declare a template function
template <class Type>
int generic_compare(Type element1, Type element2)
{
   if (element1 < element2)
      return(-1);
   if (element1 == element2)
      return(0);
   if (element1 > element2) 
      return(1);
}

// using the function with different types
int a, b;
char m, n;
double x, y;

result = generic_compare(a, b);
result = generic_compare(m, n);
result = generic_compare(x, y);

Coroutines

Often it is desirable to have two routines exchange control at key execution points, rather than having one routine call the other and wait for it to complete.

For instance we might wish execution to proceed as follows:

In such an arrangement, the routines are referred to as coroutines.

Simula 67 and Modula 2 are two languages that support the coroutine concept.

Subprograms and stacks

There are a number of implementation details we will consider with respect to the use of subprograms in statically-scoped languages:

Quick review: von Neumann architectures

The underlying hardware can have a substantial impact on the efficiency of some of the programming language features we use.

Here we will consider some key issues with respect to von Neumann architectures, and their relevance in data manipulation and program control structures.

Core components:
The core components of a computer system in a von Neuman architecture are:

Program execution cycles:
The core steps in the execution of a program are as follows:

Note that there is always a program running on the computer: typically the operating system software includes a program which runs the entire time the computer is active

It is responsible for things like

Common memory addressing modes:
All the executable instructions and data for a program are stored within the computer memory while the program executes

The machine code instructions supported by the computer control logic typically allow only a limited number of different methods to access data in memory (aka addressing modes).

Since the data accesses and control structures of higher level languages must eventually be carried out by sequences of such machine code instructions, it is worthwhile briefly reviewing the common data access methods:

Being aware of the implementation issues associated with the language constructs you use can make you a more effective programmer, but also be aware of the relative importance of readability and efficiency for your project.

On a related topic, it is also useful to be aware of the relative execution times required for different kinds of operation.

The table below might give ball park figures for the relative speeds of different kinds of operation (this is highly dependent on the hardware and operating system):

Subprogram calls using stacks

Here we'll consider a stack-based approach to maintaining the necessary information. The run-time stack is simply a stack, stored in a large block of memory, with associated operations to push data onto the stack, pop data off of the stack, and use pointers (or a similar mechanism) to access specific locations within the stack.

When source code from high level languages such as C, C++, Pascal, Ada, etc are compiled into machine code, the subroutine calls and returns are translated into sequences which include instructions to push data onto the stack and retrieve data from the stack.

Similarly, references to variables, constants, and parameters within the source code are translated into machine code sequences with appropriate accesses into the stack.

As we shall see, some of the actions which must be carried out by the machine code sequences are quite simple, while others are significantly more advanced.

We will start with the basic call and return features, then add parameter passing, return values, local variable allocation, and references to non-local variables.

Simple calls and returns
When the compiler translates a subroutine call, the resulting machine code will carry out a set of actions similar to:

So, with execution now begun in the called routine, we can logically view the stack as follows (assuming the stack grows "upward"):

|                             |
+-----------------------------+<--- Top of stack pointer
| old top-of-stack pointer    |
+-----------------------------+
| return address (old PC)     |
+-----------------------------+
|  run time stack contents    |
|    from already-active      |
|        routines             |
Eventually the called routine will complete, and the actions to be carried out at that point (again, compiled as a sequence of machine code instructions), include:

The stack, program counter, and top-of-stack pointer now look exactly as they should to continue with execution.

Adding parameter passing and return values
The actions above did not consider how to pass values between the calling and called routines, so we now add some additional steps to the process.

When the subroutine call is made the action sequence may look like:

If the function call was something like

x = foo(MyArray, MiddleInitial);

the stack might look something like:

|                             |
+-----------------------------+<--- Top of stack pointer
|  copy of MiddleInitial      |
+-----------------------------+
|                             |
|  copy of all the contents   |
|    of the array MyArray     |
|                             |
+-----------------------------+
| space for return value      |
+-----------------------------+
| old top-of-stack pointer    |
+-----------------------------+
| return address (old PC)     |
+-----------------------------+
|  run time stack contents    |
|    from already-active      |
|        routines             |
When the subroutine completes the same cleanup process is invoked, but now any necessary values must also be copied back:

(Dynamic) local variables
This still doesn't allow for dynamic local variables, which must somehow be allocated with the information for the current function call.

To do so, we again add more steps when the subroutine is called:

If the called subroutine has local variables y, z then the stack after the call to foo(MyArray,MiddleInitial) might look like:

|                             |
+-----------------------------+<--- Top of stack pointer
|   space for variable z      |
+-----------------------------+
|   space for variable y      |
+-----------------------------+
|  copy of MiddleInitial      |
+-----------------------------+
|                             |
|  copy of all the contents   |
|    of the array MyArray     |
|                             |
+-----------------------------+
| space for return value      |
+-----------------------------+
| old top-of-stack pointer    |
+-----------------------------+
| return address (old PC)     |
+-----------------------------+
|  run time stack contents    |
|    from already-active      |
|        routines             |
Upon completion, the sequence looks the same as in our previous version:

Recursive calls
Note that the mechanism described above completely supports nested function calls and recursive function calls.

Suppose we have the following recursive factorial function:

int factorial(int N)                 // line 1
{                                    // line 2
   int result;                       // line 3
   if (N < 3) result = N;         // line 4
   else {                            // line 5
     result = Factorial(N-1);        // line 6
     result = N * result;            // line 7
   }                                 // line 8
   return(result);                   // line 9
}                                    // line 10
If we call factorial(5), which in turn calls factorial(4) which calls factorial(3), then the stack might look something like:
|                             |
+-----------------------------+<--- Top of stack pointer
|   variable result           |
+-----------------------------+
|  copy of value N == 3       |              Activation
+-----------------------------+                record
| return value (will be 3)    |                 for
+-----------------------------+              factorial(3)
| old top-of-stack pointer    |--+
+-----------------------------+  | points 
| return (address of line 8)  |  |   to
+-----------------------------+<-+  here ----------------
|   variable result           |
+-----------------------------+
|  copy of value N == 4       |              Activation
+-----------------------------+                record
| return value (will be 12)   |                 for
+-----------------------------+              factorial(4)
| old top-of-stack pointer    |--+
+-----------------------------+  | points 
| return (address of line 8)  |  |   to
+-----------------------------+<-+  here ----------------
|   variable result           |             
+-----------------------------+            
|  copy of value N == 5       |           
+-----------------------------+              Activation
| return value (will be 60)   |                record
+-----------------------------+                 for
| old top-of-stack pointer    |--+           factorial(5)
+-----------------------------+  | points 
| return (address of line 8)  |  |   to
+-----------------------------+<-+  here ----------------
|  run time stack contents    |
|    from already-active      |
|        routines             |
During each execution of the factorial routine, accesses to N and result are done via offsets from the current stack pointer, so even though the execution instruction sequence is similar the data values being accessed are different.

Referencing non-local variables
One last issue to address is how we access non-local variables.

This can take two forms: blocks within subroutines, and nested subroutine declarations (where access to non-local (but possibly non-global) variables is determined by the static program structure.

The problem in the latter case is that, while the structure is known statically, we need to ensure that the specific instance referred to is the correct one.

For instance, suppose we have a language (such as Pascal) that allows nested declarations of functions, and that function blah is declared within function foo.

Now, suppose foo calls itself recursively, and then the more recent call to foo calls blah.

Within blah we should have access to the (non-local) variables of foo, but specifically to the most recent call of foo.

This means that from a called subroutine we must be able to identify not only which routines are their static ancestors, but also to identify the most recent stack activation records for each of those ancestors.

Consider the following skeleton for a program with nested declarations:

program main
   procedure A-inside-main
       procedure B-inside-A
           // B statements
       end-B
       procedure C-inside-A
           procedure D-inside-C
               // D statements
           end-D
           // C statements, 
           // including calls to D
       end-C
       // A statements,
       // including calls to B and C
   end-A
   // main statements,
   // including calls to A
end-main
We will use an additional stack value with each subroutine to point to the static ancestor of the routine.

This might be pushed immediately after the copy of the old stack pointer.

Suppose procedure D is called from procedure C above, then the stack might look like:

|                             |
+-----------------------------+<--- Top of stack pointer
| space for D's locals        |
+-----------------------------+
| space for D's parameters    |
+-----------------------------+
| space for D's return value  |
+-----------------------------+ static link points to the most
| ptr to D's static ancestor  | recent activation for C, in this
+-----------------------------+ case would be same as old t-o-s ptr
| old top-of-stack pointer    |    
+-----------------------------+
| return address (old PC)     |
+-----------------------------+
|  run time stack contents    |
|    from already-active      |
|        routines             |
When checking for non-local references:

In fact, the distance along the chain can be determined at compile time, which considerably simplifies the implementation

Creating scopes for blocks

One way to create a new scope for a block is simply to treat it as a subroutine call with no parameters and no return values - though this adds much of the overhead of subroutine calls without making that apparent to the programmer.

An alternative is, for each subroutine, identify how much space can be required for block variables at any one time and allocate the block space similarly to the rest of the local variable space.

The compiler can then determine appropriate offsets for block variables, knowing that conflicts cannot arise between requests in separate blocks.

For example, consider the code fragment

int foo(int b) {
   int a;
   for (int x = 1; ...) {
       for (int y = 0; ...) {
       }
   }
   for (int z = 0; ...) {
   }
}
In this, we only need space for two block variables, since z is never active at the same time as x and y

As a result, the activation record might look something like:

|                             |
+-----------------------------+<--- Top of stack pointer
| space for block var y       |
+-----------------------------+
| space for block vars x, z   |
+-----------------------------+
| space for local var a       |
+-----------------------------+
| space for parameter b       |
+-----------------------------+
| space for return value      |
+-----------------------------+
| ptr to foo's static ancestor|
+-----------------------------+
| old top-of-stack pointer    |    
+-----------------------------+
| return address (old PC)     |
+-----------------------------+
|  run time stack contents    |
|    from already-active      |
|        routines             |

Dynamic scoping

In dynamic scoping we require a different form of access to non-local references.

Rather than following the static chain of links to search for the desired value, we can follow the chain of dynamic links (i.e. the "old" stack pointers)

Unfortunately, this technique requires that the activation records actually store the name of the corresponding local variables and a search at each level to find a match.

This results in substantially slower access times to non-local variables in dynamically-scoped languages.

An alternative implementation, that allows faster variable access, is as follows:

This gives faster access to non-local references, but adds considerable overhead at the start and end of subroutine calls as all the local variable stacks are adjusted.