Introduction to Programming Languages/Evaluation Strategies
Evaluation Strategies
editThe parameter evaluation strategy adopted by a programming language defines when parameters are evaluated during function calls. There are two main strategies: strict and lazy evaluation.
Strict Evaluation
editThe strict evaluation strategy consists in the full evaluation of parameters before passing them to functions. The two most common evaluation strategies: by-value and by-reference, fit into this category.
Call-by-Value: The call-by-value strategy consists in copying the contents of the actual parameters into the formal parameters. State changes performed in the formal parameters do not reflect back into the actual parameters. A well-known example of this type of behavior is given by the swap function below, implemented in C:
void swap(int x, int y) {
int aux = x;
x = y;
y = aux;;
}
int main() {
int a = 2;
int b = 3;
printf("%d, %d\n", a, b);
swap(a, b);
printf("%d, %d\n", a, b);
}
Once the swap function is called, the contents of variables a and b are copied to the formal parameters x and y respectively. The data exchange that happens in the body of swap only affect the formal parameters, but not the actual ones. In other words, the swap call is innocuous in this program. In order to circumvent this semantics, the language C lets us use pointers to pass the address of a location, instead of its contents. Thus, the function below swaps the contents of two variables, as intended:
void swap(int *x, int *y) {
int aux = *x;
*x = *y;
*y = aux;
}
int main() {
int a = 2;
int b = 3;
printf("%d, %d\n", a, b);
swap(&a, &b);
printf("%d, %d\n", a, b);
}
The call-by-value strategy is very common among programming languages. It is the strategy of choice in C, Java, Python and even C++, although this last language also supports call-by-reference.
Call-by-Reference: whereas in the call-by-value strategy we copy the contents of the actual parameter to the formal parameter, in the call-by-reference we copy the address of the actual parameter to the formal one. A few languages implement the call-by-reference strategy. C++ is one of them. The program below re-implements the swap function, using the call-by-reference policy:
void swap(int &x, int &y) {
int aux = x;
x = y;
y = aux;
}
int main() {
int a = 2;
int b = 3;
printf("%d, %d\n", a, b);
swap(a, b);
printf("%d, %d\n", a, b);
}
In C++, parameter passing by reference is a syntactic sugar for the use of pointers. If we take a careful look into the assembly code that g++, the C++ compiler, produces for the function swap, above, and the function swap with pointers, we will realize that it is absolutely the same.
The call-by-reference might be faster than the call-by-value if the data-structures passed to the function have a large size. Nevertheless, this strategy is not present in the currently main-stream languages, but C++. Parameter passing by reference might lead to programs that are difficult to understand. For instance, the function below also implements the swap function; however, it combines three xor operations to avoid the need for an auxiliary variable.
void xor_swap(int &x, int &y) {
x = x ^ y;
y = x ^ y;
x = x ^ y;
}
This function might lead to unexpected results if the formal parameters x and y alias the same location. For instance, the program below, which uses the xor_swap implementation zeros the actual parameter, instead of keeping its value:
int main() {
int a = 2;
int b = 3;
printf("%d, %d\n", a, b);
xor_swap(a, a);
printf("%d, %d\n", a, b);
}
Lazy Evaluation
editThe strict evaluation strategies force the evaluation of the actual parameters before passing them to the called function. To illustrate this fact, the program below, implemented in python, loops.
def andF(a, b):
if not a:
return True
else:
return b
def g(x):
if g(x):
return True
else:
return False
f = andF(False, g(3))
There are parameter passing strategies that do not require the parameters to be evaluated before being passed to the called function. These strategies are called lazy. The three most well-known lazy strategies are call by macro expansion, call by name and call by need.
Call by Macro Expansion: many programming languages, including C, lisp and scheme, provide developers with a mechanism to add new syntax to the core language grammar called macros. Macros are expanded into code by a macro preprocessor. These macros might contain arguments, which are copied in the final code that the preprocessor produces. As an example, the C program below implements the swap function via a macro:
#define SWAP(X,Y) {int temp=X; X=Y; Y=temp;}
int main() {
int a = 2;
int b = 3;
printf("%d, %d\n", a, b);
SWAP(a, b);
printf("%d, %d\n", a, b);
}
This macro implements a valid swap routine. The preprocessed program will look like the code below. Because the body of the macro is directly copied into the text of the calling program, it operates on the context of that program. In other words, the macro will refer directly to the variable names that it receives, and not to their values.
int main() {
int a = 2;
int b = 3;
printf("%d, %d\n", a, b);
{ int tmp = (a); (a) = (b); (b) = tmp; };
printf("%d, %d\n", a, b);
}
The expressions passed to the macro as parameters are evaluated every time they are used in the body of the macro. If the argument is never used, then it is simply not evaluated. As an example, the program below will increment the variable b twice:
#define MAX(X, Y) ((X) > (Y) ? (X) : (Y))
int main() {
int a = 2, b = 3;
int c = MAX(a, b++);
printf("a = %d, b = %d, c = %d\n", a, b, c);
}
Macros suffer from one problem, called variable capture. If a macro defines a variable v that is already defined in the environment of the caller, and v is passed to the macro as a parameter, the body of the macro will not be able to distinguish one occurrence of v from the other. For instance, the program below has a macro that defines a variable temp. The call inside main causes the variable temp defined inside this function to be captured by the definition inside the macro's body.
#define SWAP(X,Y) {int temp=X; X=Y; Y=temp;}
int main() {
int a = 2;
int temp = 17;
printf("%d, temp = %d\n", a, temp);
SWAP(a, temp);
printf("%d, temp = %d\n", a, temp);
}
Once this program is expanded by the C preprocessor, we get the code below. This program fails to exchange the values of variables temp and a:
int main() {
int a = 2;
int temp = 17;
printf("%d, temp = %d\n", a, temp);
{int temp=a; a=temp; temp=temp;};
printf("%d, temp = %d\n", a, temp);
}
There are a number of lazy evaluation strategies that avoid the variable capture problem. The two best known techniques are call-by-name and call-by-need.
Call by Name: in this evaluation strategy the actual parameter is only evaluated if used inside the function; however, this evaluation uses the context of the caller routine. For instance, in the example below, taken from Weber's book, we have a function g that returns the integer 6. Inside the function f, the first assignment, e.g., b = 5, stores 5 in variable i. The second assignment, b = a, reads the value of i, currently 5, and adds 1 to it. This value is then stored at i.
void f(by-name int a, by-name int b) {
b=5;
b=a;
}
int g() {
int i = 3;
f(i+1,i);
return i;
}
Very few languages implement the call by name evaluation strategy. The most eminent among these languages is Algol. Simula, a direct descendent of Algol, also implements call by name, as we can see in this example. The call by name always causes the evaluation of the parameter, even if this parameter is used multiple times. This behavior might be wasteful in referentially transparent languages, because, in these languages variables are immutable. There is an evaluation strategy that goes around this problem: the call by need.
Call by Need: in this evaluation strategy, a parameter is evaluated only if it is used. However, once the first evaluation happens, its result is cached, so that further uses of the parameter do not require a re-evaluation. This mechanism provides the following three guarantees:
- The expression is only evaluated if the result is required by the calling function;
- The expression is only evaluated to the extent that is required by the calling function;
- The expression is never evaluated more than once, called applicative-order evaluation.
Haskell is a language notorious for using call by need. This evaluation strategy is a key feature that the language designers have used to keep Haskell a purely functional language. For instance, call by need lets the language to simulate the input channel as an infinite list, which must be evaluated only as much as data has been read. An an example, the program below computes the n-th term of the Fibonacci Sequence. Yet, the function fib, that generates this sequence, has no termination condition!
fib m n = m : (fib n (m+n))
getIt [] _ = 0
getIt (x:xs) 1 = x
getIt (x:xs) n = getIt xs (n-1)
getN n = getIt (fib 0 1) n
The getIt function expands the list produced by fib only as many times as it is necessary to read its n-th element. For instance, below we have a sequence of calls that compute the 4-th element of the Fibonacci sequence:
getIt (fib 0 1) 4
= getIt (0 : fib 1 1) 4
getIt (fib 1 1) 3
= getIt (1 : fib 1 2) 3
getIt (fib 1 2) 2
= getIt (1 : fib 2 3) 2
getIt (fib 2 3) 1
= getIt (2 : fib 3 5) 1
= 2