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.
a+b -> (+ a b) Sin(a) -> (Sin a) |
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 |
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.
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; |
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)_InNumericMode() <-- 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; |
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.
Some examples to illustrate destructive operations on lists:
In> x1:={a,b,c} Out> {a,b,c}; |
In> FullForm(x1) (List a b c ) Out> {a,b,c}; In> x2:=z:x1 Out> {z,a,b,c}; |
In> FullForm(x2) (List z a b c ) Out> {z,a,b,c}; In> x2[1]:=y Out> True; |
In> x2 Out> {y,a,b,c}; In> x1 Out> {a,b,c}; In> x2[2]:=A Out> True; |
In> x2 Out> {y,A,b,c}; In> x1 Out> {a,b,c}; In> x2:=x1 Out> {A,b,c}; |
In> x2[1]:=A Out> True; |
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"}}; |