General Recursive Functions
So far, when talking about computability, we have been concerned with the question of deciding membership of a string in a language. We might wonder how that relates to performing computations on numbers, since most programming languages separate their numeric operations from the text-processing ones.
A partial answer comes from observing that all of the standard arithmetic algorithms can be expressed as operations on strings of digits; much of the process of learning math in elementary school is concerned with ways of laying out and manipulating digits, one or two at a time, to perform calculations on multi-digit numbers. Therefore, we could express the computation of an arbitrary function as recognizing a string that describes the calculation process, where the language is the set of correctly laid out calculations of the desired form.
However, that might seem to be an unsatisfyingly indirect approach to such a fundamental task as computing a numeric function. Therefore, in this section we will sketch out an alternative presentation of the notion of what is computable, using what are known as the General Recursive Functions. In the interest of simplicity, we will restrict ourselves to functions over the natural numbers (); the ideas are easily extended to integers and rational numbers; computations over arbitrary real numbers present other challenges (how do you even specify an arbitrary real number without having to list an infinite string of digitis?), but since the "real number" types used in computers are all represented by rational numbers we may safely ignore this issue.
In the end, there will be an equivalence between computation viewed as a language recognition problem and viewed as a function computation problem. Any question about sets of strings can be turned into a question about numbers by encoding the strings, for example by assigning a code to each letter of the underlying alphabet and then concatenating those codes into a multi-digit number whose radix is the size of the alphabet. This should be no surprise to us, given our familiarity with modern computers representing all sorts of data as sequences of bits. Nevertheless, it will provide additional support for the Church-Turing Thesis to explore the general recursive function approach to computability.
Primitive Recursive Functions
We will start by showing how to build many familiar functions from just a few pieces. Each function will have an arity, giving the number of arguments that it takes; we will adopt the convenient abbreviation to stand for the sequence , so if function has arity we will write for the application of to its arguments.
The simplest functions are the constant, projection, and successor functions:
-
Constant functions: for natural numbers and , ;
-
Projection functions: for natural numbers and , ;
-
Successor function, of arity 1: .
Given a function of arity , and functions each of arity , we may form the composition by taking it to be the function of arity such that .
Finally, we may define new functions using primitive recursion. This proceeds through pattern-matching on the first argument. Given a function of arity , and another function of arity , we may construct the primitive recursion as the function of arity satisfying the following equations:
For example, consider the function . This will satisfy the equations
so the function can be seen to compute the usual natural number addition operation.
It is not difficult to see that we can equivalently view the functions built so far as those which could be written in a functional language like Scala, using pattern-matching on natural number arguments, with the restriction that the right-hand sides may only use the successor function, previously-defined functions, or a recursive call to the current function on the immediate predecessor of the argument being matched on. Thus, the following definition of a multiplication function is allowed:
def mult(y: Int, x: Int): Int = y match
case 0 => 0
case _ => plus(mult(y - 1, x), x)
However, the restriction to primitive recursion would rule out functions such as the more efficient power function from Exercise 4 of the section on Recursion and Induction:
def power(x: Int, n: Int): Int = n match
case 0 => 1
case _ => if n % 2 == 0 then
power(x * x, n / 2)
else
x * power(x, n - 1)
We can still write a power function using primitive recursion, but it needs to be in this less-efficient form (note that the difference is akin to the distinction between ordinary mathematical induction and strong induction):
def power(x: Int, n: Int): Int = n match
case 0 => 1
case _ => mult(x, power(x, n - 1))
The set of Primitive Recursive Functions that we can construct so far is still very large (even though we might not be able to write the most efficient implementations), but it turns out to have some limitations. A famous example of a non-primitive recursive function is the Ackermann function, which satisfies the following equations:
It is easy to check that and ; as increases, the function of and grows rapidly (for example, , a tower of exponentiations!). The proof that this is not definable through primitive recursion amounts to showing that it eventually grows faster than any possible primitive recursive function.
Minimization
Although it is possible to generalize primitive recursion to larger classes that include the Ackermann function,1 we will take a different approach to obtain the general recursive functions.
Given a function of arity , we may define the minimization to be the function of arity such that is the smallest making .
If there is no such , then is undefined for the arguments .
In programming terms, this is like adding a while loop to the language: the program will search through as long as is non-zero.
For example, suppose we have a remainder function which gives the remainder on performing integer division of by ; this is fairly easy to construct using primitive recursion. Then if , the expression will compute the smallest factor of greater than 1; if the result is itself, then is prime. Testing whether a number is prime can also be done with primitive recursion, but it might be more straightforward to think of it as a search.
The Ackermann function may be computed with minimization by defining a primitive recursive function which is zero only when (I will not show how to do this, but having the result available essentially allows us to compute an upper bound on the number of steps needed to compute , and primitive recursion can be used to "step through" the definition up to a given number of steps). Once we have , the Ackermann function is simply .
Since the function might be undefined for some arguments (when the search enters an "infinite loop"), we have to accept that the general recursive functions may only be partial functions. Although the general recursive functions will include all of the total recursive functions that we would expect to be able to define on the natural numbers, in general we will have no way to decide whether an arbitrary general recursive function is total. Any subset of functions for which we have proofs of totality must necessarily be a proper subset of the set of all total functions; this is a corollary to the undecidability of the Halting Problem.
Relation to Turing Machines
Evaluating a general recursive function can be expressed as a sequence of symbol manipulations that can written out on a tape and performed by a Turing machine; the demonstration is tedious but straghtforward.
The other direction is more interesting: given a Turing machine, we may encode the contents of the tape and the state of the machine as a (very large) number. The transitions of the machine can be used to define a primitive recursive function that will compute the encoded tape and state after steps, when started with the input on the tape. Additional primitive recursive functions may be constructed from : is the encoding of the tape after steps, and is 0 if the machine is in the halt state after steps. Finally, will be the encoding of the resulting tape when (if) the machine halts.
The minimization here corresponds to running the machine for an unknown, possibly unbounded, number of steps. For the function to be total, we would need to know that the machine will halt on all possible inputs. This completes our promised sketch of the equivalence between Turing machine processing and general recursive functions.