Let bindings and arity checking
The goal of this assignment is to extend the AST, parser, and interpreter of the Lambda language with some features. We will call this language Lambda+. This will require you to change the language in a similar fashion as we have been doing in class.
You are given a zip file on Canvas hw3.zip
with a starter code for the assignment. This is similar to the code for the Lambda language from class. You should add your own test cases to ensure your submission is correct. Your tasked with extending the language to Lambda+ in the following ways:
- Add the general form of
let
bindings - Add a
let*
form to the language - Arity checking for function arguments
You have to submit one language implementation with all these changes, and not 3 languages.
Notes about the starter code
The starter code for this assignment has been refactored. Specifically these are the changes:
- Definitions and environments in the code are now called
D
andE
reflecting the notation used in class notes. - All the tests have been moved from the
interp.rkt
to thetests.rkt
file. To run the tests now run thetests.rkt
file in Dr. Racket or runraco test tests.rkt
from your terminal. - There are 3 tests for the three tasks in this assignment. All 3 tests should fail. If you have implemented all these features correctly, these should pass. These tests are not complete and you should add more tests of your own to
tests.rkt
to ensure your implementation works.
General form of let
bindings
The let
bindings we discussed in class were a simplification of the general form of let
. The general form of let bindings allow you to declare multiple variables at once. An example program that binds two variables:
(let ((x 1)
(y 2))
(+ x y))
The general form of let
can bind identifiers id0
through idn
to values of expressions e0
through en
, locally for the expression e
:
(let ((id0 e0)
(id1 e1)
...
(idn en))
e)
The meaning of a let expression (let ((id0 e0) ... (idn en)) e)
is the meaning of e
(the body of the let
) when variables id0
through idn
means the value of e0
through en
respectively. The id
s are bound “in parallel.” That is, no identifier is bound in the right-hand side expression for any id, but all are available in the body. The ids must be different from each other. Here is an example to explain this:
(let ((x 1)
(y (add1 x)))
(+ x y))
The above results in an error, as declaring y
requires the x
to be defined, but all the newly declared bindings are only available in the body. Thus (+ x y)
can be evaluated, but the second binding expression is invalid in this let form.
This let
does not allow same identifier to be declared more than once. So programs like below result in an error:
(let ((x 1)
(x (add1 x)))
(add1 x))
Implement this general form of let. You may need to modify the ast.rkt
, parser.rkt
, and interp.rkt
files.
Add let*
Just like let
allows variables to be declared “in parallel”, the let*
form allows you declare variables “in sequence”. Thus, programs like:
(let* ((x 1)
(y 2))
(+ x y))
mean the same if they were written with let
. However, let*
supports latter declarations to be defined in terms of earlier declarations like:
(let* ((x 1)
(y (add1 x)))
(+ x y))
The above program is valid, and evaluates to 3
.
The general form of let*
can bind identifiers id0
through idn
to values of expressions e0
through en
, locally for the expression e
:
(let* ((id0 e0)
(id1 e1)
...
(idn en))
e)
The meaning of a let expression (let* ((id0 e0) ... (idn en)) e)
is the meaning of e
(the body of the let
) when variables id0
through idn
means the value of e0
through en
respectively. The difference from let
is that each identfier is available for use in later expressions, as well as in the body. Furthermore, the id
s need not be distinct, and the most recent binding is the one that is used.
In the following example:
(let* ((x 1)
(x (add1 x)))
(* x 2))
first x
is bound to 1
. This is used to compute (add1 x)
, i.e., 2
which is bound to the x
again. Finally, this new value of x
is used to compute the body (* x 2)
which evaluates to 4
.
Implement this form of let*
. You may need to modify the ast.rkt
, parser.rkt
, and interp.rkt
files.
Arity checking for function arguments
If a function has n formal parameters, it can only be applied to n arguments. The number of arguments to a function is called its arity. If they mismatch it results in an error. Consider the example:
(define (foo a b)
(+ a b))
(foo 1)
will result in an error, as the function foo
takes 2 arguments, but only 1 is provided.
The same thing applies for lambda functions. The following should also result in an error:
((λ (x y) (* x y)) 1)
In our implementation, we did not check for function arity when applying the function, which means our implementation might crash in unexpected ways. For this task, implement a check for function arity, i.e., check if the number formal arguments and actual arguments are same and only then evaluate the body of the function. If they are different, raise an Err
value stating arity mismatch. You can use the Racket provided function length
to find the length of lists.
Remember, there are 2 places functions are called: function definitions and lambdas.
Testing
You should test your code by writing test cases and adding them to the tests.rkt
file. Use the command raco test tests.rkt
to test your code. Alternatively, pressing “Run” in Dr. Racket will also run your test cases.
For grading, your submitted interpreter will be tested on multiple programs drawn from this Lambda+ language. Writing your own test cases will give you confidence that your interpreter can handle previously unseen programs.
Submitting
You should submit on Gradescope. You should submit three files: ast.rkt
, parser.rkt
, and interp.rkt
files for grading, so make sure all your work is contained there! You may add any function you need to these files, but do not rename the parse
and interp
functions.