CSC 530 Lecture Notes Week 2

CSC 530 Lecture Notes Week 2
Discussion of Assignment 1
Topics from the Lisp Primer
Topics from Part 1 of the Readings




  1. Reading this week -- papers 1-4 on functional programming.

  2. Discussion of Assignment 1
    1. What are you doing here?
      1. You're writing a Lisp interperter very much like the early implementors of Lisp did, about 40 years ago.
      2. At this point, our concern is not with the beauty of functional versus imperative languages, but rather with getting an interpreter to work in a simple and elegant way.
      3. After your initial experience with this implementation, we'll reflect on how to improve things, including applying principles of formal semantics to make the interpreter more general and more powerful.
    2. The read-xeval-print-loop (in test-files/read-xeval-print.l)
      ; This is an "x" version of Lisp's read-eval-print loop.  When the function
      ; read-xeval-print-loop is run, it will interactively input sexpr's from
      ; the terminal and hand them over to xeval.  Note that alist is defined as
      ; a prog var, that is sent automatically as a parameter to xeval.
      ;
      (defun read-xeval-print-loop ()
        (prog (alist result)
          (setq alist '(nil))
          loop
              (princ "X>")
              (setq result (xeval (read) alist))
              (princ (car result))
              (setq alist (cadr result))
              (terpri)(terpri)
              (go loop)
        )
      )
      
      1. Notice that it's grossly imperative, with the use of setq's and go's (it's Mr. Hack at work, as opposed to Dr. Fisher).
      2. Notice that the return value of xeval is a pair of the form
        (computed-value-of-sexpr possibly-updated-alist)
      3. By providing the alist as an explicit input parameter and returning it as part of the return value, xeval does not require a global store, in contrast to the normal Lisp eval.
    3. The meat of the matter in Assignment 1 is the structure of the alist.
      1. The good-news/bad-news situation with Lisp is that due to weak typing, there is no explicit declaration of the alist structure anywhere in the template.
      2. The good news is that weak typing allows high flexibility in how data structures are manipulated.
      3. The bad news is that in order to determine what the structure of data is, one has to examine the program code closely.
      4. In the case of Assignment 1, my clue to you for how to structure the alist is in its dump shown in the file xeval-test-demo.
      5. This structure can accurately be called a naive alist layout.

  3. Details of the "naive" alist layout
    1. Fundamentally, the alist is a list of bindings.
    2. The general form of a binding is
      ( name value )
      
    3. For Lisp, bindings can be subdivided into two categories:
      1. A variable binding is a pair of the form:
        ( var-name data-value )
        
      2. Function binding is a triple of the form
        ( function-name formal-parms function-body )
        
    4. Note that we distinguish between variable versus function bindings by their lengths -- two and three, respectively.
    5. In the Lisp of Assignment 1, bindings can be created and modified in three ways:
      1. Variable bindings are created and modified by (xsetq x v) as follows:
        1. If there is no existing binding for x on the alist, add the new binding (x v) to the end of the alist.
        2. If there is an existing binding for x on the alist, change the value of the binding to v.
      2. Function bindings are created and modified by (xdefun f parms body) in precisely the same way as variable bindings with setq; namely
        1. If there is no existing binding for f on the alist, add the new binding (f parms body) to the end of the alist, where parms is a list of formal parameters of the form ( p1 ... pn ).
        2. If there is an existing binding for f on the alist, change the value of the binding to parms body.
      3. Function call bindings (a.k.a., activation records) are created and removed by function calls of the form (f a1 ... an)
        1. Before the function body is evaluated, a new binding is created for each ai, of the form
          ( pi ai )
          
          and these bindings are added to the end of the alist.
        2. After the function body has been evaluated, the activation bindings are removed, by restoring the alist to its state before the bindings were added.
    6. In all cases above, the addition, removal, and search for bindings is done in a LIFO discipline
      1. Whenever a new binding is made, it is added to the end of the alist.
      2. Whenever a binding is searched for, the search starts from the end on which the most recent addition was made.
      3. And note well the phrase from above "... by restoring the alist to its state before the bindings were added".
        1. This means that any new bindings made by setq or defun during the course of function body evaluation are non-permanent.
        2. I.e., such bindings effectively become part of the activation record, and are removed after function evaluation has completed.
    7. What is naive about the above alist organization is that it does not accurately represent the scoping rules of Common Lisp.
      1. You should reflect upon this, and think about how the alist structure fails to correctly represent Common Lisp scoping rules (hint -- the answer is to this is rather subtle, and you need not know the answer to complete Assignment 1).
      2. The important point for now is that the above naive layout does work for the the test programs of Assignment 1.
    8. The bottom line for Assignment 1 --
      1. You can use whatever alist structure you like, as long as the test program successfully executes and provides the same evaluation results as mine.
      2. If you change your alist structure, your results should differ from mine only in the format of the alist dump.



    On to the Lisp Primer

  4. Selected primer topics
    1. Let's have a look at further details of the Lisp primer, to assist in your Lisp programming work.
    2. Complete details of the language are provided in the Lisp reference manual.

  5. Summary of the types of languages Lisp can be
    1. Pure Applicative -- disallow setq entirely, as well as all destructive list operations, and all imperative control constructs (though the control constructs are essentially useless without assignment).
    2. Single-Assignment Applicative -- allow let, but still no setq, no destructive's, and no other imperative features.
    3. Large-Grain Applicative -- allow setq, and imperative control, but only inside functions; i.e., no free variables in functions, and still no destructive's.
    4. Imperative -- allow general setq (both local and global), and imperative control, but still no destructive's.
    5. Nasty Imperative -- allow it all, including destructive's.



    More on Functional Programming

  6. Review of "compelling" and other motivations for applicative programming.
    1. Referential transparency, aka no side effects
    2. Verifiability
    3. Concurrency
    4. Other techniques for efficient evaluation, including lazy evaluation and memoization.

  7. The definition and implications of referential transparency.
    1. Referential transparency means that within a given context, multiple occurrences of an expression all have the same value.
    2. This is the essential meaning of "side effect free".
    3. Referential transparency has a number of important implications for applicative programs, two of which are the following:
      1. Since a given expression E is never evaluated for its side effects, all instances of E have the same value, and E need only be evaluated once in a given context.
      2. In implementations that support concurrency, non-nested expressions can be evaluated independently, and therefore in parallel.
    4. Any use of a data modification operator, such as assignment to a global variable or destructive data modification, violates referential transparency.
      1. This is the case, since an expression with side-effects can easily have a different value in the same context.
      2. E.g.,
        >(setq z 0)
        0
        
        >(defun expr (x) (setq z (+ z x)))
        expr
        
        >(defun f (x y) (+ x y z))
        f
        
        >(f (expr 1) (expr 1))
        5
        
        >(f (expr 1) (expr 1))
        11
        

  8. The general benefits of side-effect-free programming.
    1. Consider the following session:
      >(setq x '((a 10) (b 20) (c 30)))
      ((A 10) (B 20) (C 30))
      
      >(load "setfield.l")
      Loading setfield.l
      Finished loading setfield.l
      T
      
      >(setq y (assoc 'b x))
      (B 20)
      
      >y
      (B 20)
      
      >(dsetfield 'b "abc" x)
      ("abc")
      
      >x
      ((A 10) (B "abc") (C 30))
      
      >y
      (B "abc")
      
    2. The problem here is that the destructive assignment to the object pointed to by x has an effect that is not directly apparent (i.e., is non- transparent) in the call to dsetfield.
      1. Namely, the value of variable y is changed by the call to dsetfield, even though y is not an explicit argument to dsetfield.
      2. The problems with this type of indirect access are well known to imperative programmers.
      3. I.e., it can be difficult to track down the effects of such indirect access, particularly when several levels of indirection are involved.

  9. Program verifiability
    1. The most commonly practiced technique of program verification can be outlined as follows:
      1. Provide a specification of program input, stated as predicate P
      2. Provide a specification of program output, stated as predicate Q
      3. Prove that if P is true before the program is executed, then Q is true after it is executed.
    2. This verification technique relies critically on the restriction that P is a function of all possible program inputs and Q is a function of all possible program outputs.
      1. Without this restriction, the verification may not be valid, since the proof might not be true for all possible program results.
      2. Given this restriction, it is easier to verify functional programs, since all possible inputs/outputs are easier to identify than in imperative programs that reference and/or modify global or pointer variables.
    3. An important element of program verification is that the language in which verifiable programs are written must be formally defined.
      1. In general, specifying such a formal definition involves defining the meaning of each construct in the language.
      2. It is generally easier to specify the formal semantics of a functional language than an imperative language, since the effects of any language construct are more isolated in a functional language.
      3. As an introductory appeal to your intuition in this regard, let us consider the mini-Lisp interpreter of assignment 1 as a form of semantic definition.
        1. In order to define the operational semantics of imperative constructs, the alist must be "dragged around" everywhere.
        2. In contrast, to define the semantics of applicative constructs, far less manipulation of the alist is necessary.
        3. More on this in future lectures.

  10. Introduction to concurrency models.
    1. Consider the following example
      >(defun f (x y z) ... )
      
      >(f (big1 ...) (big2 ...) (big3 ...))
      
    2. The names bigi are intended to signify costly computations.
      1. Given referential transparency, it is possible to evaluate each of the big computations concurrently.
      2. This can result in clear time savings.
    3. Hence, in a purely applicative language, a basic form of concurrency is parallel evaluation of function arguments.
    4. Another interesting model for concurrent evaluation of applicative languages is dataflow, which we will introduce briefly in these notes, and discuss further in upcoming weeks.

  11. Introduction to dataflow evaluation.
    1. In the normal applicative evaluation of an expression such as (a + b) * (c - d), we can view the expression evaluation as a post-order traversal of an expression tree such as that show in Figure 1a.


      Figure 1: Two models of expression evaluation.



    2. In the tree-based evaluation model, we evaluate using a sequential depth-first traversal of the tree (post-order).
    3. In the dataflow model (Figure 1b), we evaluate as follows:
      1. Each operator may execute on its own processor.
      2. Each processor waits for the arrival of its inputs.
      3. Upon arrival of all (perhaps some) inputs, a processor proceeds with its computation, wholely independent of any other processor.
      4. Upon completion, the processor outputs its results, to whatever processor(s) may need them.
      5. The only imposition of sequential behavior is that defined by successive data dependencies, but even this can be partially eliminated with stream-based models.
    4. We will have more to say about dataflow models in upcoming lectures. In the mean time, we can refer to dataflow-style evaluation to help develop intuition about other forms of applicative evaluation.

  12. Introduction to lazy evaluation
    1. Some initial terminology
      1. For today's discussion, we will consider the following terms to be synonymous: lazy, non-strict, demand-driven.
      2. We will also consider the following terms to be synonymous: eager, strict, data-driven.
      3. In future lectures, we will consider contexts in which these synonyms may have different meanings or connotations.
    2. The normal evaluation rule in most programming language translators is "eager"
      1. Recall the fundamental rules for Lisp function evaluation:
        1. Evaluate all function arguments
        2. Evaluate the function body
      2. This form of evaluation is eager in the sense that it evaluates all arguments, even if their evaluation may not be necessary.
    3. The basic idea of lazy evaluation
      1. Suppose we choose not to evaluate arguments before the body of the function is evaluated.
      2. Rather, we will be lazy and wait until an argument is actually used in the function body to evaluate.
    4. Consider the following motivation for being lazy.
      >(defun stupid-but-lazy (x y)
          (cond ( (= 1 1) x )
                ( t y )
          )
      )
      

      >(stupid-but-lazy 1 (some-hugely-lengthy-computation))
    5. While the function here is not doing anything intelligent, the advantage of lazy evaluation should be clear.
    6. Can we be lazy in an imperative language?
      1. The answer, in general, is no.
      2. In an imperative language, we cannot guarantee that function arguments will not have side effects on which the body of the computation may rely, even if it does not directly access the argument during computation.
      3. E.g.,
        >(defun way-stupid-but-lazy (x y)
            (cond ( (= 1 1) z )      ;NOTE: z is free
                  ( t y )
            )
        )
        
        >(way-stupid-but-lazy 1 (setq z 1))
        
      4. In this example, if we're lazy in evaluating argument y, way- stupid-but-lazy will break, since z will not even be defined.
      5. It is possible to be lazy in selected segments of an imperative program.
        1. Specifically, we can perform lazy evaluation on imperative functions or subprograms that can be guaranteed to be side-effect free.
        2. The analysis required to make such guarantees is complicated (full dataflow analysis, in general), but some modern compilers are capable of performing it.

  13. How lazy do we get?
    1. In order to make sense out of lazy evaluation, we need to define when we must ultimately perform argument evaluation.
    2. In so doing, we need to define what language primitives, if not all, should be lazy.
    3. To get started, let's consider the following rules for a lazy Lisp:
      1. cond, cons, car, and cdr are lazy.
      2. All user-defined functions are lazy.
      3. print and all arithmetic/logical operations are eager.
      4. We stop being lazy when an eager function "demands" a value, or when we evaluate a literal constant value (i.e., a literal atom, number, or string).
    4. Consider the following example, where "L>" is the prompt for a lazy evaluating read-eval-print loop:
      L>(defun (lazy+ (x y) (+ x y)))
      lazy+
      
      L>(lazy+ 2 (lazy+ 2 (lazy+ 2 2)))
      8
      
    5. Let us trace carefully how evaluation of the latter expression proceeds.
      1. We start by evaluating the outermost call to lazy+.
      2. Since the first argument is a literal, we go ahead and evaluate it.
      3. Since the second arg is non-literal, we do not evaluate it, but rather proceed to evaluate the body of lazy+.
      4. Within the body of lazy+1, we find (+ 2 (lazy+ 2 (lazy+ 2 2))).
      5. Since + is eager, it demands that we evaluate both args.
        1. The first arg is trivially a constant.
        2. The second is (+ 2 (lazy+ 2 (lazy+ 2 2))), which we now proceed to evaluate.
        3. This evaluation proceeds just as in steps 1-3 above.
      6. Now we're in the body of lazy+2, where we find (+ 2 (lazy+ 2 2)).
      7. Again, + demands evaluation, so we evaluate both 2 and (lazy+ 2 2)).
      8. In the body of lazy+3, we find both args are constant, so (+ 2 2) proceeds without further ado.
      9. At this point, we are ready to unwind the three pending calls to lazy+, since the innermost one has computed a return value. I.e.,
        1. lazy+3 returns 4
        2. lazy+2 then returns 6
        3. lazy+1 finally returns 8
    6. While the result of the computation is obviously the same as with eager evaluation (it must be, in fact!), it is important to understand the order in which evaluations took place.
      1. With an eager evaluation of (lazy+ 2 (lazy+ 2 (lazy+ 2 2))), the innermost call to lazy+ would be processed first -- an inside-out order.
      2. With lazy evaluation, the order is outside-in, since lazy+ does not require argument evaluation until it is demanded by + within its body.

  14. Lazy evaluation of potentially infinite functions.
    1. An interesting aspect of lazy evaluation is that it can cope effectively with computations that may not compute at all with eager evaluation.
      1. Consider
        >(defun not-so-stupid-but-lazy (x y)
            (cond ( (= 1 1) x )
                  ( t y )
            )
        )
        
        >(defun infinite-computation ()
            (prog ()
                loop (go loop)
            )
        )
        
        >(not-so-stupid-but-lazy 1 (infinite-computation))
        1
        
      2. While the example is again simplistic, the advantage of lazy evaluation should be clear.
    2. Another particularly interesting use of lazy evaluation is in the context of potentially infinite generator functions.
      1. Consider the following example:
        >(defun all-ints () (cons 0 (1+ (all-ints))))
        all-ints
        
        >(nth 2 (all-ints))
        2
        
      2. The reader should consider the following in regard to this example:
        1. What scheme for lazy evaluation could allow the above computation to avoid infinite computation (it may or may not be the same as the scheme we used above for the lazy+ example).
        2. How exactly does the finite execution of (nth 2 (all-ints)) proceed using a successful lazy evaluation?
        3. What does GCL do with this example?
        4. How would a lazy Lisp evaluator be implemented to allow this example to compute? (This is part of an upcoming 530 assignment.)

  15. Lazy dataflow
    1. Lazy evaluation in a dataflow model is a natural idea.
    2. Rather than require that a processing node have all inputs before it begins execution, it can begin as soon as it has enough inputs.
    3. A somewhat radical approach to lazy dataflow is fully demand-driven evaluation
      1. All dataflow nodes start computation immediately.
      2. When a node comes to a point in a computation where it needs an input value, it demands it from the node on the other end of the input line.
    4. Implementation details of lazy dataflow evaluation can be complex, but the concept is a very interesting one, and it has been the subject of some good research.

  16. Introduction to memoization
    1. We noted above that referential transparency implies that any occurrence of the same expression in the same environment need only be evaluated once.
    2. Let us consider an evaluation strategy that takes direct advantage of this applicative property:
      1. The first time a function is evaluated with a particular set of arguments, compute the function.
      2. After the first evaluation, store the result for the given args in a table, indexed by the argument values, say by hashing. This is the memo of a function result.
      3. On subsequent evaluations, hash the input arguments, and look them up in the table.
        1. If a value is found for this set of args, simply return it without recomputation of the function body.
        2. Otherwise, compute and store the value for the new set of args.
    3. Can memoization be used in imperative languages?
      1. The answer is essentially the same as for lazy evaluation.
      2. Viz., memoization can be performed by imperative language compilers where they can guarantee side-effect-free program behavior.
    4. We will consider memoization in a Lisp interpreter in an upcoming assignment.

  17. Memoization in dataflow models.
    1. There are a number of interesting approaches to memoization in a dataflow model.
    2. One intuitively appealing idea is to allow the dataflow lines to remember the most recent datum(a) that passed across.
      1. In this way, the lines themselves perform the memoization.
      2. The computational nodes can reference the line memory, and if a given line has not changed value, the memo'd value can be reused.

  18. To think about
    1. Do lazy evaluation and memoization make sense together in the same evaluator?
      1. If so, how?
      2. If not, why not?
    2. These questions will be addressed in upcoming assignments.

  19. Some concluding thoughts on functional languages
    1. Even if functional languages are not used much in everyday programming, the concepts of functional programming are extremely influential.
    2. For example, compilers for completely imperative languages (e.g., C) implement memoization and lazy evaluation techniques, which techniques were pioneered in the study of functional languages.
    3. Many so-called "modern" practices of good imperative programming are based on functional programming concepts, including
      1. Lessening the use of global variables.
      2. Defining variables and function arguments to be constant where ever possible.
      3. Formally specifying program behavior using a functional specification language.
    4. Ongoing research in functional languages continues to pioneer new concepts that can be applied to programming and translation of functional and imperative languages alike.




index | lectures | handouts | assignments | examples | doc | solutions | bin