Introduction
Introduction
We want to do some calculations on a remote server. The server lets us send expressions to evaluate, one at a time, but doesn’t let us define variables or functions because that would use up memory, a Denial-of-Service Attack.
For example, we want to compute the factorial of a number, say 6, but the server doesn't have a built-in for factorial. We'd like to send the standard recursive definition
In[]:=
fact[n_]:=If[n<1,1,nfact[n-1]]
then, call it like this:
In[]:=
fact[6]
Out[]=
720
But that’s two shots, and we only get one shot. We can’t define fact, using up memory in the server’s symbol table, and then use it on the next shot.
Are we out of luck? No. In fact, The following does the trick, as this article explains:
In[]:=
(d(gg@g)[sfd[msf[sf]@m]])[fnIf[n<1,1,nf[n-1]]]@6
Out[]=
720
We’ll show how to convert any recursive function into an anonymous version of itself. Furthermore, to sweeten the deal, we’ll show how to convert expensive anonymous recursive functions into cheap anonymous recursive functions. We’ll do it in Mathematica and talk about doing it in Scheme and Python (Python version here https://github.com/rebcabin/rebcabin.github.io/blob/main/PythonYCombinators.ipynb). Along the way, we’ll uncover deep and lovely secrets about Mathematica programming, secrets that make it my favorite programming language.
Anonymous Functions
Anonymous Functions
We already know how to define functions that don’t have names: lambda expressions. Mathematica has a concise notation via esc-fn-esc. Here is a lambda expression in shorthand, one that computes its argument, x, times (x+1), its argument plus one:
In[]:=
xx*(x+1)
Out[]=
Function[x,x(x+1)]
Notation
Notation
That is a lambda expression of one argument, namely x. Read it as “the function of that produces times , or .” The star for multiplication is optional in Mathematica, so you can write x*(x+1) as x(x+1). In Scheme or Python, you must write the star.
x
x
(x+1)
x(x+1)
We know how to apply such a lambda expression to an actual argument, say, to 6: wrap the lambda expression in parentheses and follow it with an @ sign:
In[]:=
(xx(x+1))@6
Out[]=
42
Or, write the function application with square brackets like this:
In[]:=
(xx(x+1))[6]
Out[]=
42
The meaning is exactly the same. x@y means the same as x[y], no matter what x and y mean. We choose one or the other at will to satisfy subjective aesthetics.
In the following, Script letters like , ℰ, ℱ, ℒ, and , are notional names: non-denotable names, names we can’t write in our programming language, but names of denotable things we need to think about and don’t want to keep writing out verbatim over and over again. For example, we’ll see the following symbol-blizzard over and over again:
d(gg@g)[sfd[msf[sf]@m]]
That’s a literal, denotable expression that we’ll send to our server as part of other expressions. But it’s too much to look at while thinking, so we’ll just call it for the sake of discussion. In fact, explaining that expression is the whole point of this article. It’s a gadget that makes anonymous recursive functions and passes them into domain code for application; twisty!
Recursion as Squaring the Square Root
Recursion as Squaring the Square Root
It turns out we can evaluate anonymous recursive functions in one shot. We’ll do it by taking the square root of the function ℱ that we want, in a notional space where squaring the square root is applying to itself.
ℱ
ℱ
ℱ
For example, we want fact, but the server doesn’t let us define the name fact. But the server does let us define temporary names that go away in one shot. Those names are the formal parameters of lambda expressions. So if we can define and then apply it to itself — square it — we get the same effect as fact.
fact
More generally, for any function ℱ, pass as an actual argument to . When is invoked, bind the actual argument to the parameter sf. In the body of , refer to ℱ by the expression sf[sf]; square the square root to get the recursive function ℱ that we want. What a great trick!
ℱ
ℱ
ℱ
ℱ
ℱ
==ℱ
ℱ
ℱ
=2
ℱ
ℱ
Is this a Joke?
Is this a Joke?
How did we get from applying a function to itself to squaring a function ? It’s a pun, but not a joke! In the world of functions at large, linear functions are very, very important and special. Applying a linear function to an argument is multiplication, often matrix multiplication, seen everywhere. . This pun is so powerful that a whole programming language, Haskell, writes all function applications, linear or not, without parentheses, as if function application were always multiplication. It’s not, be we always wish it were.
ℱ
ℱ
2
ℱ
f
g
f(g)=fg
The Square Root of Factorial
The Square Root of Factorial
Turns out we can easily compute for any function ℱ. We do fact as an example, first, then generalize.
ℱ
To get the square root of factorial ℱ, just assume it exists and has a name, sf, in the only allowed place for named entities, as the parameter of an anonymous lambda expression. Let the return value of be the function of that computes factorial. How? By invoking sf[sf], the square — the self-application — of sf, in the recursive position where we call ℱ on . What is the value of sf? Just ! So must be factorial, and we can apply it to numerical arguments, here 6:
ℱ
sf=
ℱ
n
n-1
ℱ
ℱ
ℱ
In[]:=
((sfnIf[n<1,1,nsf[sf][n-1]])@(sfnIf[n<1,1,nsf[sf][n-1]]))@6
Out[]=
720
Before the final actual argument, 6, and its application symbol, @, there is a lambda expression of one parameter sf applied to a cut-and-paste copy of its whole self via another application symbol @ on the first line. sf is a function of itself, sf, that returns a function of . Applying sf to sf squares the function . Inside the If[...] part, we see another self application, sf[sf], applied to a numerical argument, sf[sf][n-1]. So we see that the external squaring, ((sf...)@(sf...)) produces the same result as the internal squaring sf[sf]. The external squaring is applied to the application argument, 6 in this case.
n
ℱ
This is enough. Stop here if all you care about is a programming pattern for anonymous, remotable, recursive functions: just replace the body of the function, namely the If[...] part, in both places where it occurs, with the body of your desired recursive function, and call your function recursively via the self-application syntax sf[sf]. We’ll show another example, fib, later.
However, there are worthwhile improvements, particularly to remove copy-paste repetition, an opportunity for error when sf gets big. We can write a general function that squares the square root of any function ℱ.
Two Abstractions, One Model, and Packaging
Two Abstractions, One Model, and Packaging
To refresh the main idea: we have a square root , that, when squared, produces the recursive domain function ℱ of the domain parameters; ℱ does the real work we want.
ℱ
Let’s make a combinator (a function of a function) that can convert any function into a new function that receives its self application, the recursive function f=sf[sf], as its first argument. This is a twist on the prior development. We want sf[sf] as the value of the first parameter f. We want to write ((...)[fn...])@6 in our example, with f as the domain function sf[sf] and n as the domain parameter. We must solve for (...).
Solve in two steps: first, start with the prior development, in which sf is the parameter in a cut-and-paste squaring of . Inside the domain code — inside — replace the square sf[sf] by the parameter f of a new anonymous function of f. Apply the new anonymous function of f to the actual argument sf[sf].
ℱ
ℱ
That’s what abstraction means in general: replacing an expression ℰ with a parameter e of a new anonymous function, then applying that new function to ℰ as an actual argument, which becomes the value of the parameter e.
In the second step, abstract the domain code into a parameter d of the final, general combinator , so that we write the domain code only once.
Here are all the improvements, spelled out for factorial:
Step 1: Abstract the Internal Self-Application
Step 1: Abstract the Internal Self-Application
Looking at the body of , namely the If[...] part of
ℱ
sfnIf[n<1,1,nsf[sf][n-1]],
the first task is to abstract the internal self-application sf[sf] into a parameter f of a new abstracted function of f applied to sf[sf] (or to sf@sf, same thing). This is exactly as we had before, only with f standing in for sf[sf].
sf((fnIf[n<1,1,nf[n-1]])[sf@sf]),
The fragment highlighted in yellow, below, is the new abstracted function of f. However, this new abstraction blows up — fails to terminate — even before it is applied to a numerical argument:
In[]:=
(sf((fnIf[n<1,1,nf[n-1]])[sf@sf]))@(*sfisstillinscope!*)(sf((fnIf[n<1,1,nf[n-1]])[sf@sf])(*sfisstillinscope!*));
Why? Let’s calculate. Let sℱ, notionally, stand for this function of sf that binds sf@sf to the parameter f:
ℱ=(sf((fnIf[n<1,1,nf[n-1]])[sf@sf]))
Out[]=
Function[sf,Function[f,Function[n,If[n<1,1,nf[n-1]]]][sf[sf]]]
Apply sℱ — this function of sf — to a copy of itself exactly as before:
In[]:=
ℱ@ℱ;
This can’t work. The argument in the application ((f...)[sf@sf]), namely sf@sf, is evaluated to sℱ@sℱ, via the value of sf as copy-paste code, before being bound to f, but there we go evaluating sf@sf in the pasted copy again before knowing what it is! Evaluating arguments before binding them is called applicative-order evaluation or call-by-value. It’s the norm in practical programming languages like Mathematica, C, Scheme, Python, Lisp, and most things we’re familiar with, and it’s too early for this job.
◼
Aside: An alternative is called normal-order evaluation or call-by-name, but we can’t have it directly without support from the programming language.
This unbounded recursion has nothing to do with the computation If[n<1,1,n*f[n-1]] inside (f...), we never get there. The following blows up with the computation removed and replaced by a constant return value.
In[]:=
(sf(f0)[sf@sf])@(sf(f0)[sf@sf]);
Delay the Squaring
Delay the Squaring
We can delay evaluation of sf[sf] by redefining ℱ to apply, instead of f, yet another anonymous function:
In[]:=
msf[sf]@m
Out[]=
Function[m,sf[sf][m]]
to the numerical argument, as follows
In[]:=
(sfnIf[n<1,1,n*(msf[sf]@m)[n-1]]);
We’ve temporarily lost the abstraction of sf[sf] into f, but gained a delayed evaluation of sf[sf]. We’ll get f back in a minute.
msf[sf]@m always has the same value as sf[sf] when applied to any argument. The two expressions just evaluate sf@sf at different times. In the first case, sf@sf is evaluated later when msf[sf]@m is applied to the actual argument n, substituting the value of n for the parameter m.
◼
This is a general technique for delaying the application of any function: replace the application with a function of some (any) parameter, the new function getting evaluated at the correct time.
◼
In lazy languages like Haskell, this step is automatic and implicit — we don’t write it — because evaluation of all expressions is always delayed.
Let’s back off and write our very first original self-application with msf[sf][m] manually in place of f.
In[]:=
(sfnIf[n<1,1,n(msf[sf]@m)[n-1]])[sfnIf[n<1,1,n(msf[sf]@m)[n-1]]]@6
Out[]=
720
Now, as before, abstract msf[sf]@m, merely a delayed version of sf[sf], into a parameter f of a new lambda:
In[]:=
(sf(fnIf[n<1,1,nf[n-1]])[msf[sf]@m])[sf(fnIf[n<1,1,nf[n-1]])[msf[sf]@m]]@6
Out[]=
720
the result does not spin forever because evaluation of sf[sf] is delayed until needed on the argument n==6.
Step 2: Abstract the Domain Code
Step 2: Abstract the Domain Code
The abstraction on f now completely and minimally encloses the domain code
=fnIf[n<1,1,n f[n-1]].
Abstract that:
1. Write a new function (d...) of a parameter d for domain code
2. ... with a body that replaces application of the old function of f to the delayed square of sf
(f...)[m...], with an application of d to the same argument, d[m...]
(f...)[m...], with an application of d to the same argument, d[m...]
3. Apply that new function (d...) to the old function of f: (d...)[f...]
We get a big benefit: we only write the domain code once, and that’s a big deal, especially if it’s big and complicated. Don’t Repeat Yourself is a general principle in software engineering. It saves pitfalls now, during development, and later, during maintenance.
Step 3: Model the Self-Application
Step 3: Model the Self-Application
with an application of gg@g to sf[msf[sf]m]:
Demonstrate the Generality
Demonstrate the Generality
Try out the outer combinator — which always stays the same — on some new domain code, which we only write once:
Step 4: Packaging as a Combinator
Step 4: Packaging as a Combinator
Package the outer combinator as a notional function . Its only argument is the domain code!
and test on our two examples
Step 5: More Arguments
Step 5: More Arguments
Because we have a good grip on how works, we can write the two-argument version straightaway, without a hiccup! Functions of two arguments are invoked in Curried fashion, one argument at a time, as in f[a][b] rather than f[a,b]. Such Currying makes it trivial to extend to any number of arguments.
My made-up function explodes rapidly. The following are the only results on positive-integer inputs that don’t overflow Mathematica’s recursion limits.
That' s a nice segue into ...
Memoizing [sic]
Memoizing [sic]
A downside of recursive functions is that they are often expensive. That “different famous recursive function” mentioned above is Fibonacci, a schoolbook case of exponential complexity, certainly a Denial-of-Service attack both on stack space and on computation time. Calling our anonymous version of Fibonacci on 27 feels really slow (more than 2 seconds), and exponentially slower for bigger arguments: 10 seconds on 30 and a couple of minutes on 35. Don’t try it on 40, it might not finish in your lifetime.
Our paranoid evaluation server has a time limit of a quarter of a second or so and would kill our jobs.
Are we out of luck for recursive functions? NO!
If our evaluator lets us define temporary symbols, and it always does so as function parameters, and if the built-ins give us hash tables, dictionaries, association lists, or some such, we can build tables of intermediate values and avoid recursive calls. This, also, is a general technique, the simplest instance of dynamic programming, and it’s called memoization [sic, not memorization].
Mathematica automatically has a hash table, DownValues, for every symbol. So memoizing Fibonacci is nearly trivial, if we can name it:
Orders of magnitude faster, linear instead of exponential, just by saving intermediate values onto the DownValues hash-table via mfib[n] = ...
The expressions m[n-1] and m[n-2] then become table lookups instead of recursive calls almost all the time. Wolfram perhaps foresaw this in 1980 when he designed Mathematica to use the same notation [] for table lookup in DownValues as for function-call. Actually, it’s inherent in the term-rewriting method of Mathematica, but it’s brilliant, however it was conceived, even if only by accident.
We can’t easily exploit Mathematica’s DownValues: function parameters don’t have them. Even so, it doesn’t port easily to non-symbolic programming languages like Python. Instead, we’ll use Mathematica’s Association, which is like Python’s dictionary.
The idea for linearizing the exponential process of Fibonacci by memoizing is as follows:
1
.Factor our domain code d to do “lookup,” check-or-install a given key-value pair in an Association. Use it twice, that’s why we want to name it rather than write it out verbatim twice.
3
.Check the association before recursively calling; this is the critical step for avoiding exponential run time.
4
.Incrementally add new key-values pairs to the association before returning it to recursive calls already on the stack.
We’ll use Mathematica’s Module to save keyboarding. Module allocates local variables. In an ordinary language like Scheme, we’d use let to allocate temporary variables. Let is 100% equivalent to Scheme lambda applied to arguments, where the symbolic parameter of the lambda is the temporary. In Mathematica, Module is likewise equivalent to a lambda applied to arguments, but only when the parameters are not mutated. That’s because Mathematica is actually a term-
rewriter instead of an applicative-order-evaluator. Mathematica feels free to throw away parameters, replacing them with values. Function parameters are not first-class, mutable symbols in Mathematica; they’re pattern variables. The distinction between mutable symbols and pattern variables has many profound benefits, to many and too deep to go into here. Let us just say that distinction is syntactic in Mathematica and one of the reasons I prefer Mathematica to Scheme and Haskell.
rewriter instead of an applicative-order-evaluator. Mathematica feels free to throw away parameters, replacing them with values. Function parameters are not first-class, mutable symbols in Mathematica; they’re pattern variables. The distinction between mutable symbols and pattern variables has many profound benefits, to many and too deep to go into here. Let us just say that distinction is syntactic in Mathematica and one of the reasons I prefer Mathematica to Scheme and Haskell.
We want to mutate the values to avoid grotesque argument threading. We trust we could do this entire thing with only lambdas and no Modules, but it would be longer and not instructive.
Here is memoized Fibonacci applied to 400. Without memoization, this computation would not complete in 15 billion years. Here, it takes 35 milliseconds.
Finally, just to show that we could eliminate at least the outer Module, is an equivalent expression:
CITE THIS NOTEBOOK
CITE THIS NOTEBOOK
Squaring the Square Root of a Function (Y Combinator Revisited)
by Brian Beckman
Wolfram Community, STAFF PICKS, February 20, 2024
https://community.wolfram.com/groups/-/m/t/3127112
by Brian Beckman
Wolfram Community, STAFF PICKS, February 20, 2024
https://community.wolfram.com/groups/-/m/t/3127112
