Having done the heavy lifting in expressions, adding conditionals will seem like a vacation.
We want to support two new BASIC elements:
Boolean expressions, using the relational operators < > = and <> (meaning “not equal”) and the logic operators and or and not. In BASIC, every Boolean expression evaluates to 0 (denoting false) or 1 (denoting true).
The if statement tests a condition, which is a Boolean expression. If the condition is true, we execute the statement after then, otherwise the statement after else. If there is no else statement, the program continues to the next line.
Alternatively, the if condition can be a numerical expression, which is treated as false if zero, true otherwise. The then and else statements can also be numerical expressions, which are treated as the destination of a goto.
1 2 3 4 5 6 | #lang basic-demo-2 10 x = 3 20 if x > 0 then print x else 5 * 10 30 x = x - 1 40 goto 20 50 print "done" |
1 2 3 4 | 3 2 1 done |
This tutorial section is largely reinforcement of things we learned in previous sections. So let’s jump right in to the implementation.
As we have before, we start by updating "lexer.rkt" to recognize the new tokens. To the reserved-terms list we add if, then, and else; our new relational operators (<, >, and <>); plus and, or, and not. = is also a relational operator, but it’s already included on our reserved-terms list (because of its existing role as an assignment operator):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 | #lang br (require brag/support) (define-lex-abbrev digits (:+ (char-set "0123456789"))) (define-lex-abbrev reserved-terms (:or "print" "goto" "end" "+" ":" ";" "let" "=" "input" "-" "*" "/" "^" "mod" "(" ")" "if" "then" "else" "<" ">" "<>" "and" "or" "not")) (define basic-lexer (lexer-srcloc ["\n" (token 'NEWLINE lexeme)] [whitespace (token lexeme #:skip? #t)] [(from/stop-before "rem" "\n") (token 'REM lexeme)] [reserved-terms (token lexeme lexeme)] [(:seq alphabetic (:* (:or alphabetic numeric "$"))) (token 'ID (string->symbol lexeme))] [digits (token 'INTEGER (string->number lexeme))] [(:or (:seq (:? digits) "." digits) (:seq digits ".")) (token 'DECIMAL (string->number lexeme))] [(:or (from/to "\"" "\"") (from/to "'" "'")) (token 'STRING (substring lexeme 1 (sub1 (string-length lexeme))))])) (provide basic-lexer) |
Next we add the new parser rules.
First, we need a b-if rule to handle the if statement, with a mandatory then clause and an optional else. We can have either a statement or an expression after the then and else, so our whole rule will look like this:
1 2 | b-if : /"if" b-expr /"then" (b-statement | b-expr) [/"else" (b-statement | b-expr)] |
We also add b-if as one of the choices for our b-statement rule.
Then we need rules for our logical operators. Just like the operators in mathematical expressions, logical operators are ranked from lowest to highest precedence:
or
and
not
The relational operators (< > = and <>)
After that, all the mathematical operators have higher precedence than the relational operators.
We already know how to enforce precedence—by chaining together parser rules. So we insert our logical operators into the existing tower of precedence:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 | #lang brag b-program : [b-line] (/NEWLINE [b-line])* b-line : b-line-num [b-statement] (/":" [b-statement])* [b-rem] @b-line-num : INTEGER @b-statement : b-end | b-print | b-goto | b-let | b-input | b-if b-rem : REM b-end : /"end" b-print : /"print" [b-printable] (/";" [b-printable])* @b-printable : STRING | b-expr b-goto : /"goto" b-expr b-let : [/"let"] b-id /"=" (b-expr | STRING) b-if : /"if" b-expr /"then" (b-statement | b-expr) [/"else" (b-statement | b-expr)] b-input : /"input" b-id @b-id : ID b-expr : b-or-expr b-or-expr : [b-or-expr "or"] b-and-expr b-and-expr : [b-and-expr "and"] b-not-expr b-not-expr : ["not"] b-comp-expr b-comp-expr : [b-comp-expr ("="|"<"|">"|"<>")] b-sum b-sum : [b-sum ("+"|"-")] b-product b-product : [b-product ("*"|"/"|"mod")] b-neg b-neg : ["-"] b-expt b-expt : [b-expt ("^")] b-value @b-value : b-number | b-id | /"(" b-expr /")" @b-number : INTEGER | DECIMAL |
Between b-expr and b-sum we’ve inserted four rules for our logical operators, so that they have lower precedence than the existing mathematical operators.
For the rules themselves, we follow the same pattern that we used before, where the left element in each pattern is a recursive reference to the rule (thus enforcing left-to-right evaluation) and the right element chains to the next rule (thus enforcing precedence). The exception is b-not-expr, because like b-neg, it only takes one argument.
Because our relational operators all have the same precedence, we handle them with a single b-comp-expr rule (similar to what we did with b-sum and b-product).
We’ll start a new module called "cond.rkt" to hold our expander implementation for conditionals. Let’s first update our "elements.rkt" so it picks up this new module:
1 2 3 4 5 6 | #lang br (require "line.rkt" "go.rkt" "expr.rkt" "misc.rkt" "cond.rkt") (provide (all-from-out "line.rkt" "go.rkt" "expr.rkt" "misc.rkt" "cond.rkt")) |
Then we can start "cond.rkt" itself. First we make macros corresponding to the parser rules for our logical operators:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 | #lang br (require "go.rkt") (provide b-if b-or-expr b-and-expr b-not-expr b-comp-expr) (define (bool->int val) (if val 1 0)) (define nonzero? (compose1 not zero?)) (define-macro-cases b-or-expr [(_ VAL) #'VAL] [(_ LEFT "or" RIGHT) #'(bool->int (or (nonzero? LEFT) (nonzero? RIGHT)))]) (define-macro-cases b-and-expr [(_ VAL) #'VAL] [(_ LEFT "and" RIGHT) #'(bool->int (and (nonzero? LEFT) (nonzero? RIGHT)))]) (define-macro-cases b-not-expr [(_ VAL) #'VAL] [(_ "not" VAL) #'(if (nonzero? VAL) 0 1)]) |
Because BASIC Boolean expressions work in terms of nonzero-or-zero rather than true-or-false, we create a nonzero? predicate to test our conditions. The compose1 function is common Racket shorthand for combining multiple functions into one, so these expressions are equivalent:
Consistent with BASIC protocol, we also use a bool->int function to convert the Racket result of these operations, which will be #f or something else, to a simple 0 or 1 value.
Next we add the relational operators:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 | #lang br (require "go.rkt") (provide b-if b-or-expr b-and-expr b-not-expr b-comp-expr) (define (bool->int val) (if val 1 0)) (define nonzero? (compose1 not zero?)) (define-macro-cases b-or-expr ···) (define-macro-cases b-and-expr ···) (define-macro-cases b-not-expr ···) (define b= (compose1 bool->int =)) (define b< (compose1 bool->int <)) (define b> (compose1 bool->int >)) (define b<> (compose1 bool->int not =)) (define-macro-cases b-comp-expr [(_ VAL) #'VAL] [(_ LEFT "=" RIGHT) #'(b= LEFT RIGHT)] [(_ LEFT "<" RIGHT) #'(b< LEFT RIGHT)] [(_ LEFT ">" RIGHT) #'(b> LEFT RIGHT)] [(_ LEFT "<>" RIGHT) #'(b<> LEFT RIGHT)]) |
We create our BASIC relational operators by composing our bool->int helper function with the corresponding Racket relational operator. Our b-comp-expr macro just dispatches to the correct operator.
We finish off by adding a macro for b-if:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 | #lang br (require "go.rkt") (provide b-if b-or-expr b-and-expr b-not-expr b-comp-expr) (define (bool->int val) (if val 1 0)) (define nonzero? (compose1 not zero?)) (define-macro-cases b-or-expr ···) (define-macro-cases b-and-expr ···) (define-macro-cases b-not-expr ···) (define b= ···) (define b< ···) (define b> ···) (define b<> ···) (define-macro-cases b-comp-expr ···) (define-macro-cases b-if [(_ COND-EXPR THEN-EXPR) #'(b-if COND-EXPR THEN-EXPR (void))] [(_ COND-EXPR THEN-EXPR ELSE-EXPR) #'(let ([result (if (nonzero? COND-EXPR) THEN-EXPR ELSE-EXPR)]) (when (exact-positive-integer? result) (b-goto result)))]) |
The first case of the b-if macro handles the situation when ELSE-EXPR is omitted by adding (void) calling b-if again. At that point it will match the second case.
As we did above, we use nonzero? to test our condition. We know from our parser rules that THEN-EXPR or ELSE-EXPR might be a statement or a numerical expression, in which case it should be treated as a goto. We handle this by checking whether result is an exact-positive-integer? and if so, passing it to b-goto.
The completed "cond.rkt" module looks like this:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 | #lang br (require "go.rkt") (provide b-if b-or-expr b-and-expr b-not-expr b-comp-expr) (define (bool->int val) (if val 1 0)) (define nonzero? (compose1 not zero?)) (define-macro-cases b-or-expr [(_ VAL) #'VAL] [(_ LEFT "or" RIGHT) #'(bool->int (or (nonzero? LEFT) (nonzero? RIGHT)))]) (define-macro-cases b-and-expr [(_ VAL) #'VAL] [(_ LEFT "and" RIGHT) #'(bool->int (and (nonzero? LEFT) (nonzero? RIGHT)))]) (define-macro-cases b-not-expr [(_ VAL) #'VAL] [(_ "not" VAL) #'(if (nonzero? VAL) 0 1)]) (define b= (compose1 bool->int =)) (define b< (compose1 bool->int <)) (define b> (compose1 bool->int >)) (define b<> (compose1 bool->int not =)) (define-macro-cases b-comp-expr [(_ VAL) #'VAL] [(_ LEFT "=" RIGHT) #'(b= LEFT RIGHT)] [(_ LEFT "<" RIGHT) #'(b< LEFT RIGHT)] [(_ LEFT ">" RIGHT) #'(b> LEFT RIGHT)] [(_ LEFT "<>" RIGHT) #'(b<> LEFT RIGHT)]) (define-macro-cases b-if [(_ COND-EXPR THEN-EXPR) #'(b-if COND-EXPR THEN-EXPR (void))] [(_ COND-EXPR THEN-EXPR ELSE-EXPR) #'(let ([result (if (nonzero? COND-EXPR) THEN-EXPR ELSE-EXPR)]) (when (exact-positive-integer? result) (b-goto result)))]) |
With those improvements, our #lang basic should now be able to handle conditional expressions. Let’s start with our original test program:
1 2 3 4 5 6 | #lang basic 10 x = 3 20 if x > 0 then print x else 5 * 10 30 x = x - 1 40 goto 20 50 print "done" |
1 2 3 4 | 3 2 1 done |
A good start. Let’s try all of our relational and logical operators:
1 2 3 4 5 6 7 8 | #lang basic 10 print 2 < 4 rem 1 20 print 2 > 4 rem 0 30 print 2 = 4 rem 0 40 print 2 <> 4 rem 1 50 print 2 < 4 or 2 > 4 or 2 = 4 rem 1 60 print 2 < 4 and 2 > 4 and 2 = 4 rem 0 70 print not 2 > 4 or not 2 < 4 rem 1 |
1 2 3 4 5 6 7 | 1 0 0 1 1 0 1 |
Let’s also try the various styles of if:
1 2 3 4 5 6 7 8 9 | #lang basic 10 if 2 < 4 then print "true" else print "false" 20 if 2 > 4 then print "true" else print "false" 30 if 2 > 4 then goto 50 40 print "not" 50 print "true" 60 if 2 < 4 then 40 + 40 else 70 70 print "not" 80 print "true" |
1 2 3 4 5 | true false not true true |
Everything checks out.
At the end of expressions, we saw that we often have a choice between functions or macros for implementing elements of a language. Usually, we prefer to use functions where we can, and reserve macros for when we really need them, because macros incur some extra costs. Though in simple cases, those costs are minor.
In this case, a little reflection will reveal that we can’t implement conditionals with ordinary functions. Why not? Because conditionals come with “short-circuiting” logic guaranteeing that no result expression is evaluated until it’s needed. Consider these examples with or and and:
If or and and were implemented as functions, the nested expressions would be evaluated first, including (collapse-solar-system) and (start-zombie-apocalypse). Not what we want.
Instead, these forms are implemented as macros, which rearranges the code at compile time so that at run time, things happen in the right order (or not at all).
How, specifically? During compile-time macro expansion, we can think of these forms as wrapping their expressions in lambdas to delay their evaluation:
That way, the underlying expressions are held in “suspended animation” until they’re needed at run time.