Evaluation of expressions

When programming in some language, it helps to have a mental model of what goes on behind the scenes when evaluating expressions, or in this case simplifying expressions.

This section aims to explain how evaluation (and simplification) of expressions works internally, in Yacas.


The LISP heritage


Representation of expressions

Much of the inner workings is based on how LISP-like languages are built up. When an expression is entered, or composed in some fashion, it is converted into a prefix form much like you get in LISP:

a+b    ->    (+ a b)
Sin(a) ->    (Sin a)
Here the sub-expression is changed into a list of so-called "atoms", where the first atom is a function name of the function to be invoked, and the atoms following are the arguments to be passed in as parameters to that function.

Yacas has the function FullForm to show the internal representation:

In> FullForm(a+b)
(+ a b )
Out> a+b;
In> FullForm(Sin(a))
(Sin a )
Out> Sin(a);
In> FullForm(a+b+c)
(+ (+ a b )c )
Out> a+b+c;

The internal representation is very close to what FullForm shows on screen. a+b+c would be (+ (+ a b )c ) internally, or:

()
|
|
+  -> () -> c
       |
       |
       + -> a -> b


Evaluation

An expression like described above is done in the following manner: first the arguments are evaluated (if they need to be evaluated, Yacas can be told to not evaluate certain parameters to functions), and only then are these arguments passed in to the function for evaluation. They are passed in by binding local variables to the values, so these arguments are available as local values.

For instance, suppose we are evaluating 2*3+4. This first gets changed to the internal representation (+ (* 2 3 )4 ). Then, during evaluation, the top expression refers to function "+". Its arguments are (* 2 3) and 4. First (* 2 3) gets evaluated. This is a function call to the function "*" with arguments 2 and 3, which evaluate to themselves. Then the function "*" is invoked with these arguments. The Yacas standard script library has code that accepts numeric input and performs the multiplication numerically, resulting in 6.

The second argument to the top-level "+" is 4, which evaluates to itself.

Now, both arguments to the "+" function have been evaluated, and the results are 6 and 4. Now the "+" function is invoked. This function also has code in the script library to actually perform the addition when the arguments are numeric, so the result is 10:

In> FullForm(Hold(2*3+4))
(+ (* 2 3 )4 )
Out> 2*3+4;
In> 2*3+4
Out> 10;

Note that in Yacas, the script language does not define a "+" function in the core. This and other functions are all implemented in the script library. The feature "when the arguments to "+" are numeric, perform the numeric addition" is considered to be a "policy" which should be configurable. It should not be a part of the core language.

It is surprisingly difficult to keep in mind that evaluation is bottom up, and that arguments are evaluated before the function call is evaluated. In some sense, you might feel that the evaluation of the arguments is part of evaluation of the function. It is not. Arguments are evaluated before the function gets called.

Suppose we define the function f, which adds two numbers, and traces itself, as:

In> f(a,b):= \
In> [\
In> Local(result);\
In> Echo("Enter f with arguments ",a,b);\
In> result:=a+b;\
In> Echo("Leave f with result ",result);\
In> result;\
In> ];
Out> True;

Then the following interaction shows this principle:

In> f(f(2,3),4)
Enter f with arguments 2 3 
Leave f with result 5 
Enter f with arguments 5 4 
Leave f with result 9 
Out> 9;

The first Enter/Leave combination is for f(2,3), and only then is the outer call to f entered.

This has important consequences for the way Yacas simplifies expressions: the expression trees are traversed bottom up, as the lowest parts of the expression trees are simplified first, before being passed along up to the calling function.


Yacas-specific extensions for CAS implementations

Yacas has a few language features specifically designed for use when implementing a CAS.


The transformation rules

Working with transformation rules is explained in the introduction and tutorial book. This section mainly deals with how Yacas works with transformation rules under the hood.

A transformation rule consists of two parts: a condition that an expression should match, and a result to be substituted for the expression if the condition holds. The most common way to specify a condition is a pattern to be matched to an expression.

A pattern is again simply an expression, stored in internal format:

In> FullForm(a_IsInteger+b_IsInteger*(_x))
(+ (_ a IsInteger )(* (_ b IsInteger )(_ x )))
Out> a _IsInteger+b _IsInteger*_x;

Yacas maintains structures of transformation rules, and tries to match them to the expression being evaluated. It first tries to match the structure of the pattern to the expression. In the above case, it tries to match to a+b*x. If this matches, local variables a, b and x are declared and assigned the sub-trees of the expression being matched. Then the predicates are tried on each of them: in this case, IsInteger(a) and IsInteger(b) should both return True.

Not shown in the above case, are post-predicates. They get evaluated afterwards. This post-predicate must also evaluate to True. If the structure of the expression matches the structure of the pattern, and all predicates evaluate to True, the pattern matches and the transformation rule is applied, meaning the right hand side is evaluated, with the local variables mentioned in the pattern assigned. This evaluation means all transformation rules are re-applied to the right-hand side of the expression.

Note that the arguments to a function are evaluated first, and only then is the function itself called. So the arguments are evaluated, and then the transformation rules applied on it. The main function defines its parameters also, so these get assigned to local variables also, before trying the patterns with their associated local variables.

Here is an example making the fact that the names in a pattern are local variables more explicit:

In> f1(_x,_a) <-- x+a
Out> True;
In> f2(_x,_a) <-- [Local(a); x+a;];
Out> True;
In> f1(1,2)
Out> 3;
In> f2(1,2)
Out> a+1;


Using different rules in different cases

In a lot of cases, the algorithm to be invoked depends on the type of the arguments. Or the result depends on the form of the input expression. This results in the typical "case" or "switch" statement, where the code to evaluate to determine the result depends on the form of the input expression, or the type of the arguments, or some other conditions.

Yacas allows to define several transformation rules for one and the same function, if the rules are to be applied under different conditions.

Suppose the function f is defined, a factorial function:

10 # f(0) <-- 1;
20 # f(n_IsPositiveInteger) <-- n*f(n-1);

Then interaction can look like:

In> f(3)
Out> 6;
In> f(a)
Out> f(a);

If the left hand side is matched by the expression being considered, then the right hand side is evaluated. A subtle but important thing to note is that this means that the whole body of transformation rules is thus re-applied to the right-hand side of the <-- operator.

Evaluation goes bottom-up, evaluating (simplifying) the lowest parts of a tree first, but for a tree that matches a transformation rule, the substitution essentially means return the result of evaluating the right-hand side. Transformation rules are re-applied, on the right hand side of the transformation rule, and the original expression can be thought of as been substituted by the result of evaluating this right-hand side, which is supposed to be a "simpler" expression, or a result closer to what the user wants.

Internally, the function f is built up to resemble the following pseudo-code:

f(n)
{
   if (n = 1)
     return 1;
   else if (IsPositiveInteger(n))
     return n*f(n-1);
   else return f(n) unevaluated;
}

The transformation rules are thus combined into one big statement that gets executed, with each transformation rule being a if-clause in the statement to be evaluated. Transformation rules can be spread over different files, and combined in functional groups. This adds to the readability. The alternative is to write the full body of each function as one big routine, which becomes harder to maintain as the function becomes larger and larger, and hard or impossible to extend.

One nice feature is that functionality is easy to extend without modifying the original source code:

In> Ln(x*y)
Out> Ln(x*y);
In> Ln(_x*_y) <-- Ln(x) + Ln(y)
Out> True;
In> Ln(x*y)
Out> Ln(x)+Ln(y);

This is generally not advisable, due to the fact that it alters the behavior of the entire system. But it can be useful in some instances. For instance, when introducing a new function f(x), one can decide to define a derivative explicitly, and a way to simplify it numerically:

In> f(_x)_(Numeric) <-- Exp(x)
Out> True;
In> (Deriv(_x)f(_y)) <-- f(y)*(Deriv(x)y);
Out> True;
In> f(2)
Out> f(2);
In> N(f(2))
Out> 7.3890560989;
In> Exp(2)
Out> Exp(2);
In> N(Exp(2))
Out> 7.3890560989;
In> D(x)f(a*x)
Out> f(a*x)*a;


The "Evaluation is Simplification" hack

One of the ideas behind the Yacas scripting language is that evaluation is used for simplifying expressions. One consequence of this is that objects can be returned unevaluated when they can not be simplified further. This happens to variables that are not assigned, functions that are not defined, or function invocations where the arguments passed in as parameters are not actually handled by any code in the scripts. An integral that can not be performed by Yacas should be returned unevaluated:

In> 2+3
Out> 5;
In> a+b
Out> a+b;
In> Sin(a)
Out> Sin(a);
In> Sin(0)
Out> 0;
In> Integrate(x)Ln(x)
Out> x*Ln(x)-x;
In> Integrate(x)Ln(Sin(x))
Out> Integrate(x)Ln(Sin(x));
In> a!
Out> a!;
In> 3!
Out> 6;

Other languages usually do not allow evaluation of unbound variables, or undefined functions. In Yacas, these are interpreted as some yet undefined global variables or functions, and returned unevaluated.


Destructive operations

Yacas tries to keep as few copies of objects in memory as possible. Thus when assigning the value of one variable to another, a reference is copied, and both variables refer to the same memory, physically. This is relevant for programming; for example, one should use FlatCopy to actually make a new copy of an object. Another feature relevant to reference semantics is "destructive operations"; these are functions that modify their arguments rather than work on a copy. Destructive operations on lists are generally recognized because their name starts with "Destructive", e.g. DestructiveDelete. One other destructive operation is assignment of a list element through list[index] := ....

Some examples to illustrate destructive operations on lists:

In> x1:={a,b,c}
Out> {a,b,c};
A list x1 is created.
In> FullForm(x1)
(List a b c )
Out> {a,b,c};
In> x2:=z:x1
Out> {z,a,b,c};
A new list x2 is z appended to x1. The : operation creates a copy of x1 before appending, so x1 is unchanged by this.
In> FullForm(x2)
(List z a b c )
Out> {z,a,b,c};
In> x2[1]:=y
Out> True;
We have modified the first element of x2, but x1 is still the same.
In> x2
Out> {y,a,b,c};
In> x1
Out> {a,b,c};
In> x2[2]:=A
Out> True;
We have modified the second element of x2, but x1 is still the same.
In> x2
Out> {y,A,b,c};
In> x1
Out> {a,b,c};
In> x2:=x1
Out> {A,b,c};
Now x2 and x1 refer to the same list.
In> x2[1]:=A
Out> True;
We have modified the first element of x2, and x1 is also modified.
In> x2
Out> {A,b,c};
In> x1
Out> {A,b,c};

A programmer should always be cautious when dealing with destructive operations. Sometimes it is not desirable to change the original expression. The language deals with it this way because of performance considerations. Operations can be made non-destructive by using FlatCopy:

In> x1:={a,b,c}
Out> {a,b,c};
In> DestructiveReverse(x1)
Out> {c,b,a};
In> x1
Out> {a};
In> x1:={a,b,c}
Out> {a,b,c};
In> DestructiveReverse(FlatCopy(x1))
Out> {c,b,a};
In> x1
Out> {a,b,c};

FlatCopy copies the elements of an expression only at the top level of nesting. This means that if a list contains sub-lists, they are not copied, but references to them are copied instead:

In> dict1:={}
Out> {};
In> dict1["name"]:="John";
Out> True;
In> dict2:=FlatCopy(dict1)
Out> {{"name","John"}};
In> dict2["name"]:="Mark";
Out> True;
In> dict1
Out> {{"name","Mark"}};

A workaround for this is to use Subst to copy the entire tree:

In> dict1:={}
Out> {};
In> dict1["name"]:="John";
Out> True;
In> dict2:=Subst(a,a)(dict1)
Out> {{"name","John"}};
In> dict2["name"]:="Mark";
Out> True;
In> dict1
Out> {{"name","John"}};
In> dict2
Out> {{"name","Mark"}};