===== Side-effects ===== Programming with side-effects is one defining feature of **imperative** programming. A procedure produces a **side-effect** if its call produces a change in the state of a running program (the memory), apart from the returned value. We (re-)consider the following code discussed in the previous lecture: int main (){ int* v = malloc(sizeof(int)*7); v[0] = 1; v[1] = 9; v[2]=4; v[3]= 15; v[4] = 2; v[5] = 6; v[6] = 0; show(v); insertion_sort(v,7); show(v); } The two identical calls on a (hypothetical) ''show'' functions will produce different effects. The **state** of ''v'' has changed after the ''insertion_sort'' call. Programming with side-effects is perhaps one of the most wide-spread styles, and it may seem difficult/unnatural to refrain from using it. However, there are situations where using side-effects may be produce a code prone to bugs. Consider the following implementation of a **depth-first traversal** of an oriented graph: #include #include #include #define NODE_NO 3 // graph represented as adjacency matrix typedef struct Graph { int** m; int nodes; }* Graph; Graph make_graph (int** m, int n){ struct Graph* g = (struct Graph*)malloc(sizeof(struct Graph)); g->m = m; g->nodes = n; return g; } int visited[NODE_NO] = {0,0,0}; void visit(Graph g, int from){ visited[from] = 1; printf(" Visited %i ",from); int i; for (i = 0; inodes; i++){ if (g->m[from][i] && !visited[i]) visit(g,i); } } int main (){ int** a = (int**)malloc(NODE_NO*sizeof(int*)); a[0] = (int*)malloc(NODE_NO*sizeof(int)); a[0][0] = 0; a[0][1] = 1; a[0][2] = 0; a[1] = (int*)malloc(NODE_NO*sizeof(int)); a[1][0] = 0; a[1][1] = 0; a[1][2] = 1; a[2] = (int*)malloc(NODE_NO*sizeof(int)); a[2][0] = 0; a[2][1] = 0; a[2][2] = 0; Graph g = make_graph((int**)a,3); visit(g,0); //visit(g,1); } * A graph is represented as an **adjacency matrix**; nodes are identified by integers. * The code relies on the global array ''visited'' which marks those nodes which have been visited during the traversal. * The procedure ''visit'' initiates the traversal: it marks the initial node (''from'') as visited and recursively visits all adjacent nodes. ==== Side-effects may easily introduce bugs ==== The problem with this implementation is that ''visited'' is modified as a side-effect. As it is, two subsequent calls on ''visit'' will produce different results: int main () { visit(g,0); // produces a correct traversal visit(g,1); // produces an invalid traversal } This happens because ''visited'' was not initialised before the second use of ''visit'', and it still holds the visited nodes from the previous traversal. This bug can be easily solved in different ways: * make sure ''visited'' flags all nodes as not visited before each traversal; * use a ''visited'' array for each traversal; This example is important because: * side-effects introduce a bug which may be difficult to identify at compile-time (the program will compile) as well as at run-time (the problem only occurs during subsequent traversals, and does not produce a crash). * the programmer needs to enforce a **discipline** regarding the scope of a side-effect (e.g. what variables **outside of ''visit''** are allowed to be modified by ''visit''). Such a discipline may naturally lead to an object-oriented programming style (relying on encapsulation) or to a functional style (limiting or completely forbidding side-effects). ==== Side-effects may be unpredictable ==== Consider the following code: int fx (){ printf("Hello x !\n"); return 1;} int fy (){ printf("Hello y !\n"); return 2;} void test_call(int x, int y){} int main(){ test_call(fx(),fy()); } Both functions ''fx'' and ''fy'' produce a side-effect, since they display a message, apart from the returned value. Programmers would generally assume that ''test_call'' will display: Hello x ! Hello y ! However this is not generally the case. In the C standard, there is no specification on the order in which parameters are evaluated. As it happens, on some compilers, ''test_call'' will produce the above output, while on others (e.g. [[https://ideone.com/BAsVzC | here]]), the messages are in a different order. This is another situation where side-effects may produce hard-to-find bugs. ===== Recursion ===== Recursion is a natural way of expressing algorithms, for instance the computation of the **nth Fibbonacci number**: int fib (int n){ if (n<2) return n; return fib(n-1)+fib(n-2); } ==== Recursion may be inefficient ==== The above implementation is inefficient since it triggers an **exponential** number of ''fib'' calls. For instance, the call ''fib(4)'' will trigger the following sequence of stack modifications. In the diagram below, each line represents the state of the **call stack**. The first function call corresponds to the top of the stack. f 4 f 3 | f 4 f 2 | f 3 | f 4 f 1 | f 2 | f 3 | f 4 //f(1) returns 1; f 0 | f 2 | f 3 | f 4 //f(0) returns 0; f 1 | f 3 | f 4 //f(2) returns 1+0; f 2 | f 4 //f(1) returns 1; f(3) returns 1+1 f 1 | f 2 | f 4 f 0 | f 2 | f 4 //f(1) returns 1; [] //f(2) returns 1+0(again), f(4) returns 2 + 1 By solving the recurrence ''T(n) = T(n-1) + T(n-2) + O(1)'' we can determine the number of function calls. Conceptually, the problem with this implementation is that it re-computes previously-computed values (e.g. ''f(2)'' is computed twice). ==== Solving the call explosion problem ==== We can easily rewrite the implementation of ''fifo'' by introducing an accumulator: int fib_aux (int f1, int f2, int i, int n){ if (i == n) return f2; return fib_aux(f2,f1+f2,i+1,n); } int fib2 (int n){ return fib_aux(0,1,0,n); } The call ''fib2(4)'' produces the following sequence of calls: fib2 4 fib_aux 0 1 0 4 fib_aux 1 1 1 4 fib_aux 1 2 2 4 fib_aux 2 3 3 4 fib_aux 3 5 4 4 which is linear in the value of ''n'', and does not-recompute values. However, in an imperative language such as C, the call stack can quickly become full for larger ''n''. To solve this problem, many programming languages (e.g. Scala and Haskell, but not C) implement **tail-call optimisation**. Conceptually, tail-call optimisation relies on the following reasoning. Consider the following table, in which ''call no'' represents the //address// of the function call. Call no. Stack contents Return address ---------- ------------------------- ------------------------------ 1 fib 4 2 fib_aux 0 1 0 4 return to call from 1 value 8 3 fib_aux 1 1 1 4 return to call from 2 value 8 4 fib_aux 1 2 2 4 return to call from 3 value 8 5 fib_aux 2 3 3 4 return to call from 4 value 8 6 fib_aux 3 5 4 4 return to call from 5 value 8 For instance, the call ''fib_aux 1 2 2 4'' has address ''5'' and after executing it must return to the address ''4''. An intelligent compiler may realise the following reasoning: * the call ''fib_aux 1 2 2 4'' only returns a value to the subsequent call. * hence, it makes no sense to keep this call on the stack. Instead, it may **directly** replace the call ''fib_aux 1 2 2 4'' by ''fib_aux 3 5 4 4''. Following this reasoning, after each recursive call, the stack will contain **only one call** of ''fib_aux'', as shown below: fib 4 | fib_aux 0 1 0 4 // after first recursive call fib 4 | fib_aux 1 1 1 4 // after second recursive call fib 4 | fib_aux 1 2 2 4 // after 3rd recursive call fib 4 | fib_aux 2 3 3 4 // after 4th recursive call fib 4 | fib_aux 3 5 4 4 // after 5th recursive call fib 4 | 5 5 Note that the same reasoning **does not hold for the implementation of ''fib''**, since the recursive calls: ''fib(n-1) + fib(n-2)'' require an additional computation (addition) before returning the value. For tail-end optimisation to take place: **a recursive function must be tail-end**. A recursive function is **tail-end** if it returns a call //to itself// without any additional computation. Tail-call optimisation in C may be implemented (for ''fib_aux'') as follows: fib_aux (int f1, int f2, int i, int n){ start: if (i == n) return f2; int nf1 = f2; int nf2 = f1+f2; int ni = i+1; int nn = n; //this instruction may be later eliminated by subsequent optimisation stages. f1 = nf1; f2 = nf2; i = ni; nn = n; goto start; } ===== Higher-order functions ===== ==== Overview ==== Higher-order functions are a programming style which supports **modularisation**, by replacing recurring programming patterns by a common procedure. Using higher-order functions is not possible in any programming language, and requires **being able to pass arbitrary functions as parameter**. Suppose we have a list implementation in C which is //concealed// by the following constructors and observers: int head (List l); List tail (List l); List cons (int e, List t); void show (List l); int size (List l); as well as the implementation of the addition and product functions over list elements: int sum (List l){ if (l == Nil) return 0; return head(l)+sum(tail(l)); } int product (List l){ if (l == Nil) return 1; return head(l)*product(tail(l)); } Let us ignore the fact that both recursive functions are not tail end (thus inefficient). Both implementations follow a common pattern, which only differs in two aspects: * the **value** which is returned when **the list is empty** * the **operation** which is performed between the first member of the list, and the recursive call We could express this common pattern by the following implementation: int fold (int init, int(*op)(int,int), List l){ if (isEmpty(l)) return init; return op(head(l),fold(init,op,tail(l))); } The function ''fold'' receives as parameter exactly the initial value, and the operation to be performed. Next, addition and product can be rewritten as: int op_sum (int x, int y){return x + y;} int op_product (int x, int y){return x * y;} int op_size (int x, int y){return 1 + y;} int sum (List l) {return fold(0,op_sum, l);} int product (List l) {return fold(1,op_product, l);} which is a more modular code. ''fold'' is called a **higher-order function**, because its behaviour is defined with respect to another function (here **op**). We shall review and improve this definition later on. One possible criticism to ''fold'' is that **it is not tail-recursive**, however it can be rewritten as: int tail_fold (int init, int(*op)(int,int), List l){ if (l == Nil) return init; return tail_fold(op(head(l),init),op,tail(l)); } However, ''fold'' and ''tail_fold'' are not different only with respect to efficiency. They also have different behaviours. Consider the call: fold(i, op, [a,b,c]) op (a, fold(i,op, [b,c])) op (a, op (b, fold (i, op, [c]))) op (a, op (b, op (c, fold (i, op, [])))) op (a, op (b, op (c, i))) a op (b op (c op i)) versus: tail_fold(i, op, [a,b,c]) tail_fold(op(a,i),op, [b,c]) tail_fold(op(b,op(a,i)),op, [c]) tail_fold(op(c,op(b,op(a,i))),op,[]) c op (b op (a op i)) The two fold implementations are only the same if 'op' is commutative (addition and product happen to be commutative). ==== Question ==== How can ''sum'' be implemented using ''fold'' ? ===== Programming with higher-order functions is difficult in C ===== One criticism to the ''fold''/''tail_fold'' implementations is that they only work for operations over integers. There are many situations where folding may be useful of operations of other types. Consider the following implementation for list concatenation, which cannot be supported by our fold implementations: List append (List l1, List l2) {return fold(l2,append_op,l1);} List append_op(int e, List l2) {return cons(e,l2);} For instance, ''append([1,2,3], [4,5,6])'' will produce: fold([4,5,6], append_op, [1,2,3]) cons(1, fold([4,5,6], append_op, [2,3])) cons(1, cons(2, fold([4,5,6], append_op, [3]))) cons(1, cons(2, cons(3, fold([4,5,6], append_op, [])))) cons(1, cons(2, cons(3, [4,5,6]))) [1,2,3,4,5,6] The above implementation of append cannot rely on our fold, since the type of op supported by ''fold'' is: ''int(*op)(int,int)'', while ''append_op'' has type ''List(*op)(int,List)''. There are possible fixes to this issue (e.g. implementing another fold to support such operation types, or using void pointers) however none are clear/natural enough to actually be used in real code. ===== Introduction to Haskell ===== Haskell is a **purely-functional** programming language. In short the term **pure** refers to the fact that **side-effects are not permitted by design**. Thus, expressions of the form: x = expr do not produce side-effects. They create a variable ''x'' which is **permanently bound** to the expression ''expr'' for the entire program execution. The variable ''x'' is **immutable**: **it cannot be modified**. Haskell also **supports higher-order functions**. The implementation of ''fold'' is as follows: fold op acc l = if empty(l) then acc else op x (fold op acc xs) However, function definitions in Haskell allow specifying //conditional behaviour// depending on how **objects are constructed** (i.e. base constructors). This is called **pattern-matching** and deserves a more elaborate discussion (which will be done in a future lecture). Here, we merely state that ''[]'' and '':'' (cons) are the base constructors for lists. The code using pattern matching becomes: foldr op acc [] = acc foldr op acc (x:xs) = op x (foldr op acc xs) The name (foldr) suggests the order in which the operation ''op'' is applied (to the **r**ight). We also note that ''foldr'', although is not tail-recursive, is actually **efficient** in Haskell. This is related to the evaluation order, which will be discussed in detail in a future lecture. In Haskell, ''tail_fold'' is called ''foldl'' (to the **l**eft), and is implemented slightly different: foldl op acc [] = acc foldl op acc (x:xs) = foldl op (op x acc) xs The Haskell implementation calls ''(op x acc)'' instead of ''(op acc x)'' and there is no particular reason for (or against) this choice. Remember that is only holds in Haskell. Other fold functions implemented in other languages may work differently. The functions ''foldr'' and ''foldl'' are implemented by-default in Haskell, and can be directly used to define addition, product, or the size of a list: sum = foldr (+) 0 prod = foldr (*) 1 size = foldr (1+) 0 as well as other functions on lists: reverse l = foldl (:) [] l append l1 l2 = foldr (:) l2 l1 ===== Recommended Reading ===== * [[http://worrydream.com/refs/Hughes-WhyFunctionalProgrammingMatters.pdf|Why Functional Programming Matters]] * [[https://wiki.haskell.org/Why_Haskell_matters|Why Haskell Matters]]