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 tog
. 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 off
was evaluated during the condition check (to2
), 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 ofg
, and is true. Hence the program returns0
.
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 expressiong(3,3)-4
is evaluated to2
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 evaluateg(g(3,3)-4,g(3,3)-4)-4
. However, technically, this expression is viewed by the runtime as:g(thunk,thunk)-4
, wherethunk
is a pointer to the expressiong(3,3)-4
. This expression has already been evaluated, hence we have the callg(2,2)-4
, and the comparison succeeds.
Note that, during call-by-need, we only have two function calls of g
(instead of 4).
Lazy evaluation in Haskell
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.