13. pyxc: elif Chains

What I Am Building

Chapter 12 added if/else as a statement, but nothing between them. For more than two branches, I'm forced to nest:

def sign(x):
    if x > 0:
        return 1.0
    else:
        if x == 0:
            return 0.0
        else:
            return -1.0

After this chapter, I can write the same logic flat:

ready> def sign(x):
    if x > 0:
        return 1.0
    elif x == 0:
        return 0.0
    else:
        return -1.0
Parsed a function definition.

Source Code

git clone --depth 1 https://github.com/alankarmisra/pyxc-llvm-tutorial
cd pyxc-llvm-tutorial/code/chapter-13

Grammar

I add one alternative to if-statement: zero or more elif clauses between the if clause and the optional else:

code/chapter-13/pyxc.ebnf

 program                           = [ end-of-lines ]
                                     [ top-level-item
                                       { end-of-lines top-level-item } ]
                                     [ end-of-lines ] ;
 end-of-lines                      = end-of-line { end-of-line } ;
 top-level-item                    = function-definition
                                     | external
                                     | top-level-expression ;
 function-definition               = "def" function-signature ":"
                                     ( simple-statement
                                       | end-of-lines block ) ;
 external                          = "extern" "def" function-signature ;
 top-level-expression              = expression ;
 function-signature                = name "(" [ parameters ] ")" ;
 parameters                        = parameter { "," parameter } ;
 parameter                         = name ;
 if-statement                      = "if" expression ":" suite
+                                    { [ end-of-lines ] "elif" expression ":" suite }
                                     [ [ end-of-lines ] "else" ":" suite ] ;
 for-statement                     = "for" [ "var" ] name "=" expression ","
                                     expression "," expression ":" suite ;
 variable-statement                = "var" variable-binding
                                     { "," variable-binding } ;
 assignment-statement              = lvalue "=" expression ;
 simple-statement                  = return-statement
                                     | variable-statement
                                     | assignment-statement
                                     | expression ;
 compound-statement                = if-statement | for-statement ;
 statement                         = simple-statement | compound-statement ;
 suite                             = simple-statement
                                     | compound-statement
                                     | end-of-lines block ;
 return-statement                  = "return" expression ;
 statement-separator               = end-of-lines | BLOCK_END ;
 block                             = indent statement
                                     { statement-separator statement } dedent ;
 expression                        = comparison ;
 comparison                        = sum { comparison-operator sum } ;
 comparison-operator               = "==" | "!=" | "<=" | ">=" | "<" | ">" ;
 sum                               = term { ("+" | "-") term } ;
 term                              = factor { ("*" | "/" | "%") factor } ;
 lvalue                            = name ;
 variable-binding                  = name [ "=" expression ] ;
 factor                            = "-" factor | primary ;
 primary                           = name-expression
                                     | number-expression
                                     | parenthesized-expression ;
 name-expression                   = name | call-expression ;
 call-expression                   = name "(" [ arguments ] ")" ;
 arguments                         = expression { "," expression } ;
 number-expression                 = number ;
 parenthesized-expression          = "(" expression ")" ;
 indent                            = INDENT ;
 dedent                            = DEDENT ;
 name                              = (letter | "_")
                                     { letter | digit | "_" } ;
 number                            = digit { digit } [ "." { digit } ]
                                     | "." digit { digit } ;
 letter                            = "A".."Z" | "a".."z" ;
 digit                             = "0".."9" ;
 end-of-line                       = "\r\n" | "\r" | "\n" ;
 comment                           = "#" { comment-character } ;
 comment-character                 = ? any character except "\r" and "\n" ? ;
 whitespace                        = " " | "\t" | "\v" | "\f" ;
 INDENT                            = ? synthetic token emitted by lexer when indentation increases ? ;
 DEDENT                            = ? synthetic token emitted by lexer when indentation decreases ? ;
 BLOCK_END                         = ? synthetic token injected into the stream by ParseBlock
                                       immediately after it consumes DEDENT ? ;

New Token and Keyword

One new token:

tok_elif = -22,

Added to the keyword table, right alongside if and else:

{"if", tok_if},         {"elif", tok_elif},     {"else", tok_else},

And to the token-name map used in error messages:

{tok_if, "'if'"},          {tok_elif, "'elif'"},
{tok_else, "'else'"},

Refactoring If/Elif Parsing to Collect Branches

Before this chapter, ParseIfStatement parsed exactly one condition and one body. Now I collect an arbitrary number of (condition, body) pairs in a loop before I even know whether an else follows:

static unique_ptr<ExpressionNode> ParseIfStatement() {
  getNextToken(); // eat 'if'
  vector<pair<unique_ptr<ExpressionNode>, unique_ptr<ExpressionNode>>> Branches;
  bool LastBranchWasBlock = false;
  bool LastBranchHadTrailingEol = false;

  while (true) {
    auto Cond = ParseExpression();
    if (!Cond)
      return nullptr;

    if (CurrentToken != tok_colon)
      return LogErrorExpression("Expected ':' after if/elif condition");
    getNextToken(); // eat ':'

    auto Body = ParseSuite();
    if (!Body)
      return nullptr;

    LastBranchWasBlock = (CurrentToken == tok_block_end);
    if (LastBranchWasBlock)
      getNextToken();
    LastBranchHadTrailingEol = (CurrentToken == tok_eol);

    Branches.push_back({std::move(Cond), std::move(Body)});
    consumeNewlines();

    if (CurrentToken != tok_elif)
      break;
    getNextToken(); // eat 'elif'
  }
  // ...
}

Each pass through the loop parses one if or elif branch — I don't distinguish between them; the first iteration happens to follow if, and every iteration after that follows elif. After each body, I call consumeNewlines() and check whether elif comes next. If it does, I eat it and loop again. Anything else — else, a dedent, end of file — and I break out.

LastBranchWasBlock and LastBranchHadTrailingEol are the same bookkeeping Chapter 12 already needed for a bare if/else, just tracked per-branch now instead of once.

Missing colon after an elif condition:

ready> def bad(x):
    if x > 0:
        return 1
    elif x == 0
        return 0
    else:
        return -1
Error (Line 4, Column 16): Expected ':' after if/elif condition
    elif x == 0
               ^~~~
Error (Line 5, Column 9): Unexpected indentation
        r
        ^~~~
Error (Line 6, Column 5): unknown token when expecting an expression
    else:
    ^~~~
Error (Line 7, Column 9): Unexpected indentation
        r
        ^~~~

The first line is the real error — the parser bails out of ParseIfStatement the moment the colon check fails. Everything after that is the parser trying to recover from a token stream that no longer makes sense, the same cascading-error behavior Chapter 12 already showed for bad indentation.

Lowering to a Nested If Tree

I don't introduce a new AST node for elif. Once the loop above exits, I check for a trailing else — this part is unchanged from Chapter 12, just renamed from Then/ThenWasBlock to LastBranchWasBlock since there can now be more than one branch before it:

unique_ptr<ExpressionNode> Else;
if (CurrentToken == tok_else) {
  getNextToken(); // eat 'else'
  if (CurrentToken != tok_colon)
    return LogErrorExpression("Expected ':' after else");
  getNextToken(); // eat ':'
  Else = ParseSuite();
  if (!Else)
    return nullptr;
} else if (LastBranchWasBlock) {
  PendingTokens.push_front(CurrentToken);
  CurrentToken = tok_block_end;
} else if (LastBranchHadTrailingEol) {
  PendingTokens.push_front(CurrentToken);
  CurrentToken = tok_eol;
}

Then I lower the whole chain to a right-nested IfStatementNode tree: the (possibly null) else body becomes the initial innermost node, and I walk Branches in reverse, wrapping one more IfStatementNode around it per branch:

// I lower the chain to nested IfStatementNodes in the else branch.
unique_ptr<ExpressionNode> Tree = std::move(Else);
for (auto It = Branches.rbegin(); It != Branches.rend(); ++It) {
  Tree = make_unique<IfStatementNode>(std::move(It->first),
                                      std::move(It->second), std::move(Tree));
}
return Tree;

Given:

if a:    body_a
elif b:  body_b
elif c:  body_c
else:    body_d

I build:

IfStatementNode(a, body_a,
  IfStatementNode(b, body_b,
    IfStatementNode(c, body_c,
      body_d)))

If there's no else at all, Else starts as nullptr, so the innermost IfStatementNode's Else is null too — exactly what a bare if without else already produces. IfStatementNode::codegen() doesn't change at all; it has no idea whether it came from a literal if/else or from one link in an elif chain.

My codegen sees exactly what it would see for hand-written nested if/else blocks, so conditions are evaluated top to bottom, one at a time, same as nested if/else would be. elif buys me flatter source, not a different runtime shape — switch, which dispatches on a value directly instead of testing a chain of conditions, comes in Chapter 23.

Build and Run

cd code/chapter-13
cmake -S . -B build && cmake --build build
./build/pyxc
llvm-lit -v test/

Try It

ready> def sign(x):
    if x > 0:
        return 1.0
    elif x == 0:
        return 0.0
    else:
        return -1.0
Parsed a function definition.
ready> sign(5)
Parsed a top-level expression.
Evaluated to 1.000000
ready> sign(0)
Parsed a top-level expression.
Evaluated to 0.000000
ready> sign(-2)
Parsed a top-level expression.
Evaluated to -1.000000

More than one elif — the first matching branch wins, and later ones are never even evaluated:

ready> def mapv(x):
    if x == 1:
        return 10.0
    elif x == 2:
        return 20.0
    elif x == 3:
        return 30.0
    elif x == 4:
        return 40.0
    else:
        return 99.0
Parsed a function definition.
ready> mapv(3)
Parsed a top-level expression.
Evaluated to 30.000000
ready> mapv(4)
Parsed a top-level expression.
Evaluated to 40.000000
ready> mapv(7)
Parsed a top-level expression.
Evaluated to 99.000000

An elif chain with no else at all — same fall-through behavior a bare if has always had:

ready> def classify(x):
    var result = 0
    if x == 1:
        result = 1
    elif x == 2:
        result = 2
    return result
Parsed a function definition.
ready> classify(1)
Parsed a top-level expression.
Evaluated to 1.000000
ready> classify(2)
Parsed a top-level expression.
Evaluated to 2.000000
ready> classify(99)
Parsed a top-level expression.
Evaluated to 0.000000

classify(99) matches neither x == 1 nor x == 2, so the if falls through without touching result — it stays 0.

What's Next

Chapter 14 completes looping: while, do/while, break, and continue.

Need Help?

Build issues? Questions?

Include:

  • Your OS and version
  • Full error message
  • Output of cmake --version, ninja --version, and llvm-config --version

I'll help you figure it out.