Parameter passing in different programming languages

Different programming languages adopt different strategies for passing (and evaluating) parameters during function calls. Such strategies can be split into two categories:

  • applicative
  • normal

but different blends are possible, depending on how values are stored and passed on. We briefly review some of these strategies:

Call-by-value

In call-by-value, parameters are passed (stored on the call stack) as values. This is the case in C, as well as Java for primitive values.

void swap (int x, int y){
        int t = y;
        y = x;
        x = t;
}

We illustrate call-by-value using the swap function. In the code below:

int x = 1, y = 2;
swap(x,y);

the values of x and y do not change after the call of swap, since the values have been copied on the call stack. Call-by-value is an applicative evaluation strategy.

Call-by-reference

Call-by-reference is implemented in C explicitly via pointers, and is the default parameter-passing strategy in Java, for non-primitives (objects). We illustrate call-by-reference in C:

void swap (int* x, int* y){
       int t = *x;
       *y = *x;
       *x = t;
  }

In the code example below:

int x = 1, y = 2;
swap(&x,&y);

the values of x and y have been changed, since their addresses (instead of their values) have been passed on the call stack. Call-by-reference is an applicative evaluation strategy.

Call-by-macro expansion

Consider the following macro-definition in C:

#define TWICE(X,Y) {Y = X + X;}

In the code example:

int y;
TWICE(1+1,y);

the macro is textually-expanded without parameter evaluation, yielding y = 1 + 1 + 1 + 1 (instead of y = 2 + 2). Call-by-macro is a normal evaluation strategy, and behaves exactly like normal evaluation from the lambda calculus. Note however that macros in C are more limited than functions and do not rely on a call-stack.

Call-by-name

Call-by-name is a normal evaluation strategy, which, similar to call-by-macro expansion, reproduces lambda calculus's normal evaluation. It is not implemented per-se in programming languages, because it is inefficient. We illustrate this, by the following example:

int g(by-name int x, by-name int y){
    return x + y;
}
int f(by-name x){
    if (x == 0)
        return 0;
    return f(g(x,x)-4);
}
 
main{
    f(3);
}

where we have introduced a fictitious by-name directive, which forces the parameter at hand to be evaluated using the normal strategy.

The call f(3) will have the following behaviour:

  • since 3 == 0 is false, the following call is made:
  • f(g(3,3)-4)
    • the condition g(3,3)-4 == 0 triggers a call to g. The condition is false. Thus, the following call is made:
    • f(g(g(3,3)-4,g(3,3)-4)-4). Note that, even though the parameter of f was evaluated during the condition check (to 2), call-by-name requires that it is evaluated again:
      • the condition g(g(3,3)-4,g(3,3)-4)-4 == 0 triggers three calls of g, and is true. Hence the program returns 0.

Note that, during the call of f(3), we had a total number of 4 function calls of g, when actually 2 would have been sufficient.

Call-by-need (lazy)

Call-by-need is a normal evaluation strategy which improves call-by-name by storing a result once it is computed.

Returning to our previous example, let us replace the by-name directive with by-need. Then, the call f(3) will have the following behaviour:

  • since 3 == 1 is false, we have the following call:
  • f(g(3,3)-4). The expression g(3,3)-4 is evaluated to 2 during the comparison (which fails), and the following call is triggered:
  • f(g(g(3,3)-4,g(3,3)-4)-4). During this call, to evaluate the comparison with 0, we need to evaluate g(g(3,3)-4,g(3,3)-4)-4. However, technically, this expression is viewed by the runtime as: g(thunk,thunk)-4, where thunk is a pointer to the expression g(3,3)-4. This expression has already been evaluated, hence we have the call g(2,2)-4, and the comparison succeeds.

Note that, during call-by-need, we only have two function calls of g (instead of 4).

The default evaluation strategy in Haskell is lazy or call-by-need. Each expression in Haskell can be viewed as a thunk: a pointer which holds the expression itself, as well as its value, once it is evaluated.

We illustrate lazy evaluation by looking at the following calls:

foldr (&&) True [True,False,True,True]

Let us consider that the implementation of (&&) is as follows:

True && True = True
_ && _ = False

This expression will produce the following sequence of calls:

  • True && (foldr (&&) True [False,True,True]
  • True && (False && (foldr (&&) True [True,True]))
  • True && False
  • False

In the second pattern of &&, the function returns False without irrespective of the parameters. Hence, the call (False && (foldr (&&) True [True,True])) returns False without evaluating (foldr (&&) True [True,True]).

As it turns out, foldr may be efficient even if it is not tail-recursive, in situations where reducing a list does not require exploring all its elements.

Let us also the evaluation of:

foldl (&&) True [True,False,True,True]

which triggers:

  • foldl (&&) (True && True) [False,True,True]
  • foldl (&&) ((True && True) && False) [True,True]
  • foldl (&&) (((True && True) && False) && True) [True]
  • foldl (&&) ((((True && True) && False) && True) && True) []
  • ((((True && True) && False) && True) && True)
  • (((True && False) && True) && True)
  • ((False && True) && True)
  • (False && True)
  • False

Since the evaluation is lazy, the accumulator is only evaluated when needed, that is, when foldl returns. The result shows that foldl may be less eficient than foldr in Haskell, even if the former is tail-recursive.