Functions
Functions with Definition Terms
The simplest definition of a function consists of a function name, a signature
and a definition term. Assume the module has a type variable A: ANY
. Then
a function computing the union of two sets can be defined as
(+) (p,q:{A}): {A}
-- The union of the two sets 'p' and 'q'.
-> {x: x in p or x in q}
(+)
is the function name, (p,q:{A}): {A}
is the signature and {x: x in p
or x in q}
is its definition term. Similarly we can define the empty set as
empty: {A}
-- The empty set.
= {x: false}
A constant is a special case of a function which does not have
arguments. Instead of the arrow ->
the equal sign =
is used because a
constant function does not map any arguments.
A function might have preconditions in case that it is not defined for arbitrary values of its arguments. For functions defined with definition terms the following general forms are possible:
name: RT
-- Some constant.
= expression
name (a:A, b:B, ...): RT
-- Function without precondition.
-> expression
name(a:A, b:B, ...): RT
-- Function with precondition(s).
require
precondition_1
precondition_2
...
ensure
-> expression
end
Ghost Functions
It might be the case that a function is not computable. E.g. if the defining expression of a function contains the universal or the existential quantifier then the function is not computable. A function using non computable functions in its definition term is not computable as well.
Functions which are not defined by a computable algorithm are ghost
functions. A ghost function is declared by putting the keyword ghost
in
front of the result type.
A ghost functions is mathematically well defined but it lacks an algorithm to compute its result given its arguments.
Example: The function which returns the union or the intersection of an arbitrary collection of sets is not computable and therefore has to be declared as a ghost function.
A: ANY
(+) (ps:{{A}}): ghost {A}
-- The union of the collection of sets 'ps'.
-> {x: some(p) p in ps and x in p}
(*) (ps:{{A}}): ghost {A}
-- The intersection of the collection of sets 'ps'.
-> {x: all(p) p in ps ==> x in p}
-- Note: {A} is the type of set of elements of type A; A can be any
-- type which inherits ANY.
-- {{A}} is type of a collection of sets of elements of type A
In words:
The union of a collection of sets is the set of all elements which are at least in one of the sets of the collection of sets.
The intersection of a collection of sets is the set of all elements which are in all sets of the collection of sets.
Functions Defined by Properties
It is possible to define a function by properties which the result has to satisfy as long as the result exists and is uniquely defined.
E.g. a function is injective or one-to-one if there is only one argument which maps to the same result.
A: ANY
B: ANY
is_injective(f:A->B): ghost
-- Is the function 'f' a one-to-one mapping
-> all(a1,a2) {a1,a2} <= f.domain ==> f(a1) = f(a2) ==> a1 = a2
In words: If two elements of the domain of f
map to the same value, then the
two elements must be identical.
If a function is injective, then it is possible to map its result back to its origin. I.e. we can define the function
origin(b:B, f:A->B): ghost A
-- The origin which is mapped to 'b' by the function 'f'.
require
f.is_injective
b in f.range
ensure
Result in f.domain
f(Result) = b
end
The function origin
is defined by properties. The result of the function
must be in the domain of f
and f
maps the result to b
.
If the compiler sees this definition it verifies that the result exists and is
unique. In this particular case the verification is quite straightforward by
looking at the definition of is_injective
and range
. The function range
is defined as
range(f:A->B): ghost {B}
-> {b: some(a) a in f.domain and f(a) = b}
The second precondition of origin
requires that b
is in the range of f
which guarantees that there is some element in the domain which maps to
b
. The first precondition requires that f
is injective which guarantees
the uniqueness of the result.
Note that functions defined by properties are ghost functions because no algorithm is given to compute the result.
Recursive Functions
It is possible to define recursive functions in Albatross. Recursive functions are guaranteed to terminate because the compiler checks that each recursive call of a function uses at least one argument which is structurally smaller than the argument of the original call.
The easiest recursive functions are functions using arguments of an inductive type (see chapter Inductive Data Types) and pattern matching.
Suppose we define positive numbers in the following manner
class
POSITIVE
create
1
twice(p:POSITIVE) -- two times the positive number 'p'
twice_plus1(p.POSITIVE) -- two times the positive number 'p' plus 1
end
This is a binary definition of positive numbers.
1 1
2 1.twice
3 1.twice_plus1
4 1.twice.twice
5 1.twice.twice_plus1
6 1.twice_plus1.twice
7 1.twice_plus1.twice_plus1
8 1.twice.twice.twice
...
32 1.twice.twice.twice.twice.twice
Note how object oriented notation helps to avoid excessive parentheses. In
mathematical notation the number 8 would be written as
twice(twice(twice(1)))
.
For positive numbers we can define the successor function and addition recursively.
successor(p: POSITIVE): POSITIVE
-> inspect
p
case 1 then
1.twice
case n.twice then
n.twice_plus1
case n.twice_plus1 then
n.successor.twice
(+) (a,b: POSITIVE): POSITIVE
-> inspect
b
case 1 then
a.successor
case n.twice then
a + n + n
case n.twice_plus1 then
(a + n + n).successor
The inspect
-expression is used to do pattern matching. There are 3
possibilities to construct a number of type POSITIVE
because the class
POSITIVE
has 3 constructors.
In the successor function there is one recursive call in the third case of the
pattern matching. This case is entered if the positive number p
has been
constructed as n.twice_plus1
with some number n
. The number n
is
structurally less than p
because p
has been constructed by using n
as an
argument. Therefore n
can be used in the recursive call n.successor
.
The addition function does structural recursion on its second
argument. Recursive calls happen in the second and in the third case. The
second argument used in the recursive calls is always structurally less than
the original argument b
.
The Albatross compiler can expand recursive functions if it can determine the correct branch. It does expansion iteratively therefore it can do symbolic computations. E.g. the following theorem (see chapter Theorems) can be proved automatically by the proof engine by using iterated function expansion.
all
-- 3.twice = 4 + 2
ensure
1.twice.successor.twice = 1.twice.twice + 1.twice
end
The right hand side of the equality is successively evaluated to
1.twice.twice + 1.twice
(1.twice.twice + 1) + 1
1.twice.twice.successor.sucessor
1.twice.twice_plus1.successor
1.twice.successor.twice
1.twice_plus1.twice
More Pattern Matching
We can define division by 2 on positive numbers. However this is a partial function because 1 cannot be divided by 2.
half(p:POSITIVE): POSITIVE
require
not (p as 1)
ensure
-> inspect
p
case n.twice then
n
case n.twice_plus1 then
n
end
The precondition says that the argument p
must not have been constructed by
the constructor 1
.
Furthermore it is possible to do pattern matching on more than one variable. We can define the function less_equal on positive numbers by the following recursive function.
(<=) (a,b:POSITIVE): BOOLEAN
-> inspect
a, b
case 1,_ then
true
case n.twice, m.twice then
n <= m
case n.twice_plus1, m.twice_plus1 then
n <= m
case n.twice, m.twice_plus1 then
n <= m
case n.twice_plus1, m.twice then
n.successor <= m
case _, _ then
false
The anonymous variable _
can be used to represent arbitrary values. By using
anonymous variables it is not neccessary to list all possible
combinations. Since POSITIVE
has 3 constructors there are 9 possible
combinations. However the function just needs to distinguish between 6
cases. The first case implicitly includes all combinations of 1
with any
other constructor for the argument b
. The last case is used as a catch-all
for all remaining cases.
In each recursive call it is guaranteed that at least one argument structurally decreases.
The correctness (by correctness we mean that it corresponds to our
understanding of <=
for numbers) of the definition is evident for most of
the cases. Just the fifth case might need some thinking. This case tries to
analyze if 2n + 1 <= 2m
is valid. This is equivalent to 2n + 2 <= 2m + 1
which is equivalent to 2n' <= 2m + 1
where n'
is the successor of
n
. This is valid if n' <= m
is valid.