cat /dev/brain |

Feeding a Bison with tasty C++11 grAST!

published on Saturday, May 16, 2015

In this post I am going to demonstrate how to write a parser in concise C++ using Bison and Flex. The parser outputs an AST (abstract syntax tree) in simple C++ data structures. My main focus is to avoid overly verbose code and to keep the parsing and semantic analysis stages separate. The example code also tracks location in order to improve the usefulness of error messages. If you are looking to get the most performance out of your parser, however, this post is not for you.

For a live version of this code see my citip git repository.

Tokenizer

Flex supports thread-safe interfaces (Flex jargon — reentrant) in plain C as well as C++. Although dubbed experimental, I settled for the C++ API. The advantage of the Flex C++ API over its reentrant C counterpart is that it allows to use standard stream objects and performs automatic cleanup. By default Flex generates the code for a class with a superset of the following interface:

class yyFlexLexer {
public:
    yyFlexLexer(istream*, ostream*);
    int yylex();
};

This is the interface of a stream editor: on each call to yylex the scanner reads from its input and writes to its output stream and returns a status code. However, we don't want to write to an output stream — a Bison needs to be fed. Therefore, we must provide a replacement for the yylex method that accepts parameters for value and location. We can't just change the class yyFlexLexer which is defined in a system header. What we can do is to derive a scanner class that provides a method with the desired signature:

scanner.hpp

 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
#ifndef __SCANNER_HPP__INCLUDED__
#define __SCANNER_HPP__INCLUDED__

# undef yyFlexLexer
# include <FlexLexer.h>
# include "parser.hxx"

// Tell flex which function to define
# undef YY_DECL
# define YY_DECL        int yy::scanner::lex(                   \
                            yy::parser::semantic_type* yylval,  \
                            yy::parser::location_type* yylloc)


namespace yy
{
    class scanner : public yyFlexLexer
    {
    public:
        explicit scanner(std::istream* in=0, std::ostream* out=0);

        int lex(parser::semantic_type* yylval,
                parser::location_type* yylloc);
    };
}

#endif // include-guard

By the way, I use the extensions .hpp versus .hxx to distinguish handcrafted header files from generated ones. Anologously, the extensions .cpp and .cxx are used for source files.

The tokenizer itself is defined in a .l flex source file which consists of three sections separated by a %%. The first section can be used to set Flex options. It can also contain code blocks that will be inserted near the top of the generated .cxx file. This is useful to define convenience macros for the lexer actions in the second section.

scanner.l

 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
%option     outfile="scanner.cxx"
%option header-file="scanner.hxx"

%option c++
%option 8bit warn nodefault
%option noyywrap

%{
    #include <stdexcept>
    #include <cstdlib>
    #include "parser.hxx"
    #include "scanner.hpp"

    // utility macros to simplify the actions
    #define YIELD_TOKEN(tok, val, type)                 \
                    yylval->build<type>(val);           \
                    return yy::parser::token::T_##tok;

    #define YY_TXT                  std::string(yytext, yyleng)
    #define YY_NUM                  std::atof(yytext)

    #define INT_TOKEN(tok, val)     YIELD_TOKEN(tok, val, int)
    #define NUM_TOKEN(tok)          YIELD_TOKEN(tok, YY_NUM, double)
    #define STR_TOKEN(tok)          YIELD_TOKEN(tok, YY_TXT, std::string)
    #define LITERAL                 return yytext[0];

    // before executing an action, set the length of the location from
    // the length of the matched pattern:
    #define YY_USER_ACTION          yylloc->columns(yyleng);
%}

%%

The second section defines what the scanner actually does. You can ignore the details of the rules defined here — as these will be specific to your language. See the Flex documentation on patterns for more details. In my application, this section looks as follows:

scanner.l

33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
%{
    // before matching any pattern, update the the current location
    yylloc->step();
%}

I/\(                        LITERAL
H/\(                        LITERAL

[[:alpha:]][[:alnum:]_]*    STR_TOKEN(NAME)

[[:digit:]]+                NUM_TOKEN(NUM)
[[:digit:]]*\.[[:digit:]]+  NUM_TOKEN(NUM)

\+                          INT_TOKEN(SIGN, ast::SIGN_PLUS)
\-                          INT_TOKEN(SIGN, ast::SIGN_MINUS)

==?                         INT_TOKEN(REL, ast::REL_EQ)
\<=                         INT_TOKEN(REL, ast::REL_LE)
\>=                         INT_TOKEN(REL, ast::REL_GE)

#.*                         {/* eat comments */}
[ \t]                       {/* eat whitespace */}

\n                          yylloc->lines(1); LITERAL

                            /* forward everything else, even invalid
                             * tokens - making use of bison's automatic
                             * error messages */
.                           LITERAL

%%

The final section can contain arbitrary code. This is the perfect place to implement methods of our scanner class.

scanner.l

64
65
66
67
68
69
70
71
72
73
74
75
76
yy::scanner::scanner(std::istream* in, std::ostream* out)
    : yyFlexLexer(in, out)
{
}

// Flex generates the code for `yy::scanner::lex` (see YY_DECL).

// This must be defined manually to prevent linker errors:
int yyFlexLexer::yylex()
{
    throw std::logic_error(
        "The yylex() exists for technical reasons and must not be used.");
}

AST

Before we dive into the parser, let's have a short look at our AST:

ast.hpp

 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
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
#ifndef __AST_HPP__INCLUDED__
#define __AST_HPP__INCLUDED__

# include <string>
# include <vector>

namespace ast
{

    enum {
        SIGN_PLUS,
        SIGN_MINUS
    };

    enum {
        REL_EQ,
        REL_LE,
        REL_GE
    };

    typedef std::vector<std::string>    VarList;
    typedef std::vector<VarList>        VarCore;

    struct Quantity
    {
        VarCore parts;
        VarList cond;
    };

    struct Term
    {
        double coefficient;
        Quantity quantity;

        inline Term& flip_sign(int s)
        {
            if (s == SIGN_MINUS) {
                coefficient = -coefficient;
            }
            return *this;
        }
    };

    typedef std::vector<Term> Expression;

    struct Relation {
        Expression left;
        int relation;
        Expression right;
    };

    typedef VarCore MutualIndependence;
    typedef VarCore MarkovChain;

    struct FunctionOf {
        VarList function, of;
    };

}

#endif // include-guard

Again, you can safely ignore the details. Just note that I prefer to work with simple structs and standard library containers as opposed to classes with virtual methods. This means that I get automatic support for initializer lists and that the data is easy to keep on the stack without requiring pointer semantics. If you somewhere do need polymorphic behaviour, I recommend to use a smart pointer such as std::shared_ptr.

Parser

Bison too supports thread-safe interfaces (the Bison term being pure) in both C++ as well as plain C. The main advantage of the Bison C++ API over pure C parsers is that it allows to store the result of actions in a variant instead of a union. Apart from simplifying the access notation, this also means that even non-POD objects such as std::vector can be stored on the stack without having to worry about cleanup. We will set up Bison to generate a class with the following interface:

namespace yy {
    class parser {
    public:
        parser(yy::scanner* input, ParserOutput* output);
        int parse();
    };
}

The output callback is a simple interface to return results. The scanner argument is used to retrieve a stream of tokens by calling its lex method repeatedly.

The Bison parser is defined in a .y bison source file. This file is structured similar to the Flex file discussed above: It has three sections separated by %%. The first section has multiple purposes. We start by setting parser options:

parser.y

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
%output  "parser.cxx"
%defines "parser.hxx"

/* C++ parser interface */
%skeleton "lalr1.cc"

/* require bison version */
%require  "3.0"

/* add parser members */
%parse-param  {yy::scanner* scanner} {ParserOutput* cb}

/* call yylex with a location */
%locations

/* increase usefulness of error messages */
%define parse.error verbose

/* assert correct cleanup of semantic value objects */
%define parse.assert

%define api.value.type variant
%define api.token.prefix {T_}

Note that I omit the %define api.token.constructor directive which changes the expected signature of the yylex function to return the token value and location. On the one hand, this can be considered cleaner than passing the data back through a function argument — but it also changes the token class type from integer to something else. This means that it is no longer possible to match for plain ASCII characters in the syntax rules below.

The next step is to define tokens and semantic value types, i.e. associate the value of rules with data structures of our AST:

parser.y

24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
%token                  END     0   "end of file"

%token <std::string>    NAME
%token <double>         NUM
%token <int>            SIGN
                        REL

%type <ast::Relation>               inform_inequ
%type <ast::VarCore>                mutual_indep
%type <ast::VarCore>                markov_chain
%type <ast::FunctionOf>             determ_depen
%type <ast::Expression>             inform_expr
%type <ast::Term>                   inform_term
%type <ast::Quantity>               inform_quant
%type <ast::Quantity>               entropy
%type <ast::Quantity>               mutual_inf
%type <ast::VarList>                var_list
%type <ast::VarCore>                mut_inf_core;

%start statement

We also need this section to define code sections that will be prepended to the generated source file and/or header file:

parser.y

44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
/* inserted near top of header + source file */
%code requires {
    #include <stdexcept>
    #include <string>

    #include "ast.hpp"
    #include "location.hh"

    namespace yy {
        class scanner;
    };

    // results
    struct ParserOutput {
        virtual void relation(ast::Relation) = 0;
        virtual void markov_chain(ast::MarkovChain) = 0;
        virtual void mutual_independence(ast::MutualIndependence) = 0;
        virtual void function_of(ast::FunctionOf) = 0;
    };

    void parse(const std::vector<std::string>&, ParserOutput*);
}

/* inserted near top of source file */
%code {
    #include <iostream>     // cerr, endl
    #include <utility>      // move
    #include <string>
    #include <sstream>

    #include "scanner.hpp"

    using std::move;

    #undef yylex
    #define yylex scanner->lex

    // utility function to append a list element to a std::vector
    template <class T, class V>
    T&& enlist(T& t, V& v)
    {
        t.push_back(move(v));
        return move(t);
    }
}

%%

The second section contains our actual language specification. Most of it should be easy to grasp. The thing to note here is the use of initializer lists as a clean syntax to store values into our AST data structures. The simplicity of the grammar actions show the true power of using simple AST data types.

parser.y

 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
    /* deliver output */

statement    : %empty           { /* allow empty (or pure comment) lines */ }
             | inform_inequ     { cb->relation(move($1)); }
             | mutual_indep     { cb->mutual_independence(move($1)); }
             | markov_chain     { cb->markov_chain(move($1)); }
             | determ_depen     { cb->function_of(move($1)); }
             ;

    /* statements */

inform_inequ : inform_expr REL inform_expr       { $$ = {$1, $2, $3}; }
             ;

markov_chain : markov_chain '/' var_list               { $$ = enlist($1, $3); }
             |     var_list '/' var_list '/' var_list  { $$ = {$1, $3, $5}; }
             ;

mutual_indep : mutual_indep '.' var_list         { $$ = enlist($1, $3); }
             |     var_list '.' var_list         { $$ = {$1, $3}; }
             ;

determ_depen : var_list ':' var_list             { $$ = {$1, $3}; }
             ;

    /* building blocks */

inform_expr  : inform_expr SIGN inform_term     { $$ = enlist($1, $3.flip_sign($2)); }
             |             SIGN inform_term     { $$ = {$2.flip_sign($1)}; }
             |                  inform_term     { $$ = {$1}; }
             ;

inform_term  : NUM inform_quant                 { $$ = {$1, $2}; }
             |     inform_quant                 { $$ = { 1, $1}; }
             | NUM                              { $$ = {$1}; }
             ;

inform_quant : entropy                          { $$ = $1; }
             | mutual_inf                       { $$ = $1; }
             ;

entropy      : 'H' '(' var_list              ')'      { $$ = {{$3}}; }
             | 'H' '(' var_list '|' var_list ')'      { $$ = {{$3}, $5}; }
             ;

mutual_inf   : 'I' '(' mut_inf_core              ')'  { $$ = {{$3}}; }
             | 'I' '(' mut_inf_core '|' var_list ')'  { $$ = {{$3}, $5}; }
             ;

mut_inf_core :  mut_inf_core colon var_list     { $$ = enlist($1, $3); }
             |      var_list colon var_list     { $$ = {$1, $3}; }
             ;

colon        : ':'
             | ';'
             ;

var_list     : var_list ',' NAME                { $$ = enlist($1, $3); }
             |              NAME                { $$ = {$1}; }
             ;

%%

I should mention that this doesn't have nice performance characteristics. If you care about that it should be possible to use std::move() to move the data instead of copying it at each assignment. In my program, I decided that this wasn't worth the sacrafice of conciseness.

We are almost done now. As with flex, the final section is simply a code section that will be appended literally to the generated source. It is the right place to implement additional methods.

parser.y

153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
void yy::parser::error(const parser::location_type& l, const std::string& m)
{
    throw yy::parser::syntax_error(l, m);
}

// Example how to use the parser to parse a vector of lines:
void parse(const std::vector<std::string>& exprs, ParserOutput* out)
{
    for (int row = 0; row < exprs.size(); ++row) {
        const std::string& line = exprs[row];
        std::istringstream in(line);
        yy::scanner scanner(&in);
        yy::parser parser(&scanner, out);
        try {
            int result = parser.parse();
            if (result != 0) {
                // Not sure if this can even happen
                throw std::runtime_error("Unknown parsing error");
            }
        }
        catch (yy::parser::syntax_error& e) {
            // improve error messages by adding location information:
            int col = e.location.begin.column;
            int len = 1 + e.location.end.column - col;
            // TODO: The reported location is not entirely satisfying. Any
            // chances for improvement?
            std::ostringstream msg;
            msg << e.what() << "\n"
                << "in row " << row << " col " << col << ":\n\n"
                << "    " << line << "\n",
                << "    " << std::string(col-1, ' ') << std::string(len, '^'));
            throw yy::parser::syntax_error(e.location, msg.str());
        }
    }
}

All that remains to do now is to implement ParserOutput handlers and the actual user code.

When compiling your program with g++, don't forget to add the -std=c++11 option, i.e.:

flex scanner.l
bison parser.y
g++ -c scanner.cxx -std=c++11
g++ -c parser.cxx -std=c++11

Conclusion

Even though Flex and Bison are old tools that may seem quirky at first, their widespread availability makes them the tool of choice for many applications.

Although I'm still not entirely satisfied in every aspect, the result is probably much better than what could have been achieved with the other C++ parser generators I considered when looking for alternatives.

This shows that both tools are indeed carefully designed, adapt well and can even become easier to use in the advent of new languages features.

This entry was tagged bison, c++, flex, gist, parsing and programming

blog comments powered by Disqus