Add a draft article about X Macros
This commit is contained in:
parent
12aca7ca58
commit
4f281ef108
699
content/blog/chapel_x_macros.md
Normal file
699
content/blog/chapel_x_macros.md
Normal file
|
@ -0,0 +1,699 @@
|
|||
---
|
||||
title: "My Favorite C++ Pattern: X Macros"
|
||||
date: 2023-10-09T15:06:11-07:00
|
||||
draft: true
|
||||
tags: ["C++", "Chapel", "Compilers"]
|
||||
---
|
||||
|
||||
When I first joined the [Chapel](https://github.com/chapel-lang/chapel/) team,
|
||||
one pattern used in its C++-based compiler made a strong impression on me. Since
|
||||
then, I've used the pattern many more times, and have been very satisfied with
|
||||
how it turned out. However, it feels like the pattern is relatively unknown, so
|
||||
I thought I'd show it off, and some of its applications in the
|
||||
[Chapel compiler](https://github.com/chapel-lang/chapel/). I've slightly tweaked
|
||||
a lot of the snippets I directly present in this article for the sake of simpler
|
||||
presentation; I've included links to the original code (available on GitHub)
|
||||
if you want to see the unabridged version.
|
||||
|
||||
Broadly speaking, the "X Macros" pattern is about generating code. If you have a _lot_
|
||||
of repetitive code to write (declaring many variables or classes, performing
|
||||
many very similar actions, etc.), this pattern can save a lot of time, lead
|
||||
to much more maintainable code, and reduce the effort required to add _more_
|
||||
code.
|
||||
|
||||
I will introduce the pattern in its simplest form with my first example:
|
||||
[interning strings](https://en.wikipedia.org/wiki/String_interning).
|
||||
|
||||
### Application 1: String Interning
|
||||
The Chapel compiler interns a lot of its strings. This way, it can reduce the
|
||||
memory footprint of keeping identifiers in memory (every string `"x"` is
|
||||
actually the _same_ string) and make for much faster equality comparisons
|
||||
(you can just perform a pointer comparison!). Generally, a `Context` class
|
||||
is used to manage interning state. A new interned string can be constructed
|
||||
using the context object in the following manner:
|
||||
|
||||
```C++
|
||||
UniqueString::get(ctxPtr, "the string");
|
||||
```
|
||||
|
||||
Effectively, this performs a search of the currently existing unique strings.
|
||||
If one with the content (`"the string"` in this case) doesn't exist, it's
|
||||
created and registered with the `Context`. Otherwise, the existing string is
|
||||
returned. Some strings, however, occur a lot in the compiler, to the point that
|
||||
it would be inefficient to perform the whole "find-or-create" operation every
|
||||
time. One example is the `"this"` string, which is an identifier with a lot of
|
||||
special behavior in the language (much like `this` in languages such as Java).
|
||||
To support these frequent flier strings, the compiler initializes them once,
|
||||
and creates a variable per-string that can be accessed to get that string's value.
|
||||
|
||||
There's that repetitive code. Defining a brand new variable for each string,
|
||||
of which there are around 100 at the time of writing, is a lot of boilerplate.
|
||||
There are also at least two places where code needs to be added:
|
||||
{{< sidenote "right" "template-note" "once in the declaration of the variables, once in the code that initializes them." >}}
|
||||
A third use in the compiler is actually a variadic template defined over
|
||||
character arrays. The template is defined and specialized in such a way that
|
||||
you can refer to a variable by its string contents (i.e., you can write
|
||||
<code>USTR("the string")</code> instead of
|
||||
<code>theStringVariable</code>).
|
||||
{{< /sidenote >}}
|
||||
It would be very easy to accidentally modify the former but not the latter,
|
||||
especially for developers not familiar with how these "common strings" are
|
||||
implemented.
|
||||
|
||||
This is where the X Macros come in. If you look around the compiler source code,
|
||||
there's a header file that looks something like the following:
|
||||
|
||||
{{< githubsnippet "chapel-lang/chapel" "cd108338d321d0b3edf6258e0b2a58459d88a348" "frontend/include/chpl/framework/all-global-strings.h" "C++" 31 >}}
|
||||
X(align , "align")
|
||||
X(atomic , "atomic")
|
||||
X(bool_ , "bool")
|
||||
X(borrow , "borrow")
|
||||
X(borrowed , "borrowed")
|
||||
X(by , "by")
|
||||
X(bytes , "bytes")
|
||||
// A lot more of these...
|
||||
{{< /githubsnippet >}}
|
||||
|
||||
What's This `X` thing? That right there is the essence of the pattern: the macro
|
||||
`X` _isn't defined in the header!_. Effectively, `all-global-strings.h` is just
|
||||
a list, and we can "iterate" over this list to generate some code for each
|
||||
one of its elements, in as many places as we want. What I mean by this is
|
||||
that we can then write code like this:
|
||||
|
||||
{{< githubsnippet "chapel-lang/chapel" "cd108338d321d0b3edf6258e0b2a58459d88a348" "frontend/include/chpl/framework/global-strings.h" "C++" 76 >}}
|
||||
struct GlobalStrings {
|
||||
#define X(field, str) UniqueString field;
|
||||
#include "all-global-strings.h"
|
||||
#undef X
|
||||
};
|
||||
{{< /githubsnippet >}}
|
||||
|
||||
In this case, we define the macro `X` to ignore the value of the string (we're
|
||||
just declaring it here), and create a new `UniqueString` variable declaration.
|
||||
Since the declaration is inside the `GlobalStrings` struct, this ends up
|
||||
creating a field. Just like that, we've declared a class with over 100
|
||||
fields. Initialization is equally simple:
|
||||
|
||||
{{< githubsnippet "chapel-lang/chapel" "cd108338d321d0b3edf6258e0b2a58459d88a348" "frontend/lib/framework/Context.cpp" "C++" 49 >}}
|
||||
GlobalStrings globalStrings;
|
||||
Context rootContext;
|
||||
|
||||
static void initGlobalStrings() {
|
||||
#define X(field, str) globalStrings.field = UniqueString::get(&rootContext, str);
|
||||
#include "chpl/framework/all-global-strings.h"
|
||||
#undef X
|
||||
}
|
||||
{{< /githubsnippet >}}
|
||||
|
||||
With this, we've completely automated the code for for both declaring and
|
||||
initializing all 100 of our unique strings. Adding a new string doesn't require
|
||||
a developer to know all of the places where this is implemented: just by
|
||||
modifying the `all-global-strings.h` header with a new call to `X`, they can
|
||||
add both a new variable and code to initialize it. Pretty robust!
|
||||
|
||||
### Application 2: AST Class Hierarchy
|
||||
|
||||
Altough the interned strings are an excellent first example, it wasn't the
|
||||
first usage of X Macros that I encountered in the Chapel compiler. Beyond
|
||||
strings, the compiler uses X Macros to represent the whole class hierarchy
|
||||
of [abstract syntax tree (AST)](https://en.wikipedia.org/wiki/Abstract_syntax_tree)
|
||||
nodes that it uses. Here, the code is actually a bit more complicated; the
|
||||
class hierarchy isn't a _list_ like the strings were; it is itself a tree.
|
||||
To represent such a structure, we need more than a single `X` macro; the
|
||||
compiler went with `AST_NODE`, `AST_BEGIN_SUBCLASSES`, and `AST_END_SUBCLASSES`.
|
||||
Here's what that looks like:
|
||||
|
||||
{{< githubsnippet "chapel-lang/chapel" "cd108338d321d0b3edf6258e0b2a58459d88a348" "frontend/include/chpl/uast/uast-classes-list.h" "C++" 96 >}}
|
||||
// Other AST nodes above...
|
||||
|
||||
AST_BEGIN_SUBCLASSES(Loop)
|
||||
AST_NODE(DoWhile)
|
||||
AST_NODE(While)
|
||||
|
||||
AST_BEGIN_SUBCLASSES(IndexableLoop)
|
||||
AST_NODE(BracketLoop)
|
||||
AST_NODE(Coforall)
|
||||
AST_NODE(For)
|
||||
AST_NODE(Forall)
|
||||
AST_NODE(Foreach)
|
||||
AST_END_SUBCLASSES(IndexableLoop)
|
||||
|
||||
AST_END_SUBCLASSES(Loop)
|
||||
|
||||
// Other AST nodes below...
|
||||
{{< /githubsnippet >}}
|
||||
|
||||
The class hierarchy defined in this header, called `uast-classes-list.h`, is
|
||||
used for a lot of things, both in the compiler itself and in some libraries
|
||||
that _use_ the compiler. I'll go through the use cases in turn.
|
||||
|
||||
#### Tags and Dynamic Casting
|
||||
First, to deal with a general absence of
|
||||
[RTTI](https://en.wikipedia.org/wiki/Run-time_type_information), it is used
|
||||
to declare an "tag" enum. Each AST node has a tag matching its class;
|
||||
this allows us inspect the AST and perform safe casts similar to `dynamic_cast`.
|
||||
Note that for parent classes (defined via `BEGIN_SUBCLASSES`), we actually
|
||||
end up creating _two_ tags: one `START_...` and one `END_...`. The reason
|
||||
for this will become clear in a moment.
|
||||
|
||||
{{< githubsnippet "chapel-lang/chapel" "cd108338d321d0b3edf6258e0b2a58459d88a348" "frontend/include/chpl/uast/AstTag.h" "C++" 36 >}}
|
||||
enum AstTag {
|
||||
#define AST_NODE(NAME) NAME ,
|
||||
#define AST_BEGIN_SUBCLASSES(NAME) START_##NAME ,
|
||||
#define AST_END_SUBCLASSES(NAME) END_##NAME ,
|
||||
#include "chpl/uast/uast-classes-list.h"
|
||||
#undef AST_NODE
|
||||
#undef AST_BEGIN_SUBCLASSES
|
||||
#undef AST_END_SUBCLASSES
|
||||
NUM_AST_TAGS,
|
||||
AST_TAG_UNKNOWN
|
||||
};
|
||||
{{< /githubsnippet >}}
|
||||
|
||||
The above snippet makes `AstTag` contain elements such as `DoWhile`,
|
||||
`While`, `START_Loop`, and `END_Loop`. For convenience, we also add a couple
|
||||
of other elements: `NUM_AST_TAGS`, which is
|
||||
{{< sidenote "right" "numbering-node" "automatically assigned the number of tags we generated," >}}
|
||||
This is because C++ assigns integer values to enum elements sequentially, starting
|
||||
at zero.
|
||||
{{< /sidenote >}}
|
||||
and a generic "unknown tag" value.
|
||||
|
||||
Having generated the enum elements in this way, we can write query functions.
|
||||
This way, the API consumer can write `isLoop(tag)` instead of manually performing
|
||||
a comparison. Code generation here is actually split into two distinct forms
|
||||
of "is bla" methods: those for concrete AST nodes (`DoWhile,` `While`) and
|
||||
those for abstract base classes (`Loop`). The reason for this is simple:
|
||||
only a `AstTag::DoWhile` represents a do-while loop, but both `DoWhile`
|
||||
and `While` are instances of `Loop`. So, `isLoop` should return true for both.
|
||||
|
||||
This is where the `START_...` and `END_...` enum elements come in. Reading
|
||||
the header file top-to-bottom, we first end up generating `START_Loop`,
|
||||
then `DoWhile` and `While`, and then `END_Loop`. Since C++ assigns integer
|
||||
value to enums sequentially, to check if a tag "extends" a base class, it's
|
||||
sufficient to check if its value is greater than the `START` token, and
|
||||
smaller than the `END` token -- this means it was declared within the
|
||||
matching pair of `BEGIN_SUBCLASSES` and `END_SUBCLASES`.
|
||||
|
||||
{{< githubsnippet "chapel-lang/chapel" "cd108338d321d0b3edf6258e0b2a58459d88a348" "frontend/include/chpl/uast/AstTag.h" "C++" 59 >}}
|
||||
// define is___ for leaf and regular nodes
|
||||
// (not yet for abstract parent classes)
|
||||
#define AST_NODE(NAME) \
|
||||
static inline bool is##NAME(AstTag tag) { \
|
||||
return tag == NAME; \
|
||||
}
|
||||
#define AST_BEGIN_SUBCLASSES(NAME)
|
||||
#define AST_END_SUBCLASSES(NAME)
|
||||
// Apply the above macros to uast-classes-list.h
|
||||
#include "chpl/uast/uast-classes-list.h"
|
||||
// clear the macros
|
||||
#undef AST_NODE
|
||||
#undef AST_BEGIN_SUBCLASSES
|
||||
#undef AST_END_SUBCLASSES
|
||||
|
||||
// define is___ for abstract parent classes
|
||||
#define AST_NODE(NAME)
|
||||
#define AST_BEGIN_SUBCLASSES(NAME) \
|
||||
static inline bool is##NAME(AstTag tag) { \
|
||||
return START_##NAME < tag && tag < END_##NAME; \
|
||||
}
|
||||
#define AST_END_SUBCLASSES(NAME)
|
||||
// Apply the above macros to uast-classes-list.h
|
||||
#include "chpl/uast/uast-classes-list.h"
|
||||
// clear the macros
|
||||
#undef AST_NODE
|
||||
#undef AST_BEGIN_SUBCLASSES
|
||||
#undef AST_END_SUBCLASSES
|
||||
{{< /githubsnippet >}}
|
||||
|
||||
These helpers are quite convenient. Here are a few examples of what we end up
|
||||
with:
|
||||
|
||||
```C++
|
||||
isFor(AstTag::For) // Returns true; a 'for' loop is indeed a 'for' loop.
|
||||
isIndexableLoop(AstTag::For) // Returns true; a 'for' loop is "indexable" ('for i in ...')
|
||||
isLoop(AstTag::For) // Returns true; a 'for' loop is a loop.
|
||||
isFor(AstTag::While) // Returns false; a 'while' loop is not a 'for' loop.
|
||||
isIndexableLoop(AstTag::While) // Returns false; a 'while' loop uses a boolean condition, not an index
|
||||
isLoop(AstTag::While) // Returns true; a 'while' loop is a loop.
|
||||
```
|
||||
|
||||
On the top-level AST node class, we generate `isWhateverNode` and
|
||||
`toWhateverNode` for each AST class. Thus, user code is able to inspect the
|
||||
AST and perform (checked) casts using plain methods. I omit `isWhateverNode`
|
||||
here for brevity (its definition is very simple), and include only
|
||||
`toWhateverNode`.
|
||||
|
||||
{{< githubsnippet "chapel-lang/chapel" "cd108338d321d0b3edf6258e0b2a58459d88a348" "frontend/include/chpl/uast/AstNode.h" "C++" 313 >}}
|
||||
#define AST_TO(NAME) \
|
||||
const NAME * to##NAME() const { \
|
||||
return this->is##NAME() ? (const NAME *)this : nullptr; \
|
||||
} \
|
||||
NAME * to##NAME() { \
|
||||
return this->is##NAME() ? (NAME *)this : nullptr; \
|
||||
}
|
||||
#define AST_NODE(NAME) AST_TO(NAME)
|
||||
#define AST_LEAF(NAME) AST_TO(NAME)
|
||||
#define AST_BEGIN_SUBCLASSES(NAME) AST_TO(NAME)
|
||||
#define AST_END_SUBCLASSES(NAME)
|
||||
// Apply the above macros to uast-classes-list.h
|
||||
#include "chpl/uast/uast-classes-list.h"
|
||||
// clear the macros
|
||||
#undef AST_NODE
|
||||
#undef AST_LEAF
|
||||
#undef AST_BEGIN_SUBCLASSES
|
||||
#undef AST_END_SUBCLASSES
|
||||
#undef AST_TO
|
||||
{{< /githubsnippet >}}
|
||||
|
||||
These methods are used heavily in the compiler. For example, here's a completely
|
||||
random snippet of code I pulled out:
|
||||
|
||||
{{< githubsnippet "chapel-lang/chapel" "cd108338d321d0b3edf6258e0b2a58459d88a348" "frontend/lib/resolution/Resolver.cpp" "C++" 1161 >}}
|
||||
if (auto var = decl->toVarLikeDecl()) {
|
||||
// Figure out variable type based upon:
|
||||
// * the type in the variable declaration
|
||||
// * the initialization expression in the variable declaration
|
||||
// * the initialization expression from split-init
|
||||
|
||||
auto typeExpr = var->typeExpression();
|
||||
auto initExpr = var->initExpression();
|
||||
|
||||
if (auto var = decl->toVariable())
|
||||
if (var->isField())
|
||||
isField = true;
|
||||
{{< /githubsnippet >}}
|
||||
|
||||
Thus, developers adding new AST nodes are not required to manually implement
|
||||
the `isWhatever`, `toWhatever`, and other functions. This and a fair bit
|
||||
of other AST functionality (which I will cover in the next subsection) is
|
||||
automatically generating using X Macros.
|
||||
|
||||
{{< dialog >}}
|
||||
{{< message "question" "reader" >}}
|
||||
You haven't actually shown how the AST node classes are declared, only the
|
||||
tags. It seems implausible that they be generated using this same strategy -
|
||||
doesn't each AST node have its own different methods and implementation code?
|
||||
{{< /message >}}
|
||||
{{< message "answer" "Daniel" >}}
|
||||
You're right. The AST node classes are defined "as usual", and their constructors
|
||||
must explicitly set their <code>tag</code> field to the corresponding
|
||||
<code>AstTag</code> value. It's also on the person defining the new class to
|
||||
extend the node that they promise to extend in <code>uast-classes-list.h</code>.
|
||||
{{< /message >}}
|
||||
{{< message "question" "reader" >}}
|
||||
This seems like an opportunity for bugs. Nothing is stopping a developer
|
||||
from returning the wrong tag, which would break the auto-casting behavior.
|
||||
{{< /message >}}
|
||||
{{< message "answer" "Daniel" >}}
|
||||
Yes, it's not bulletproof. Just recently, a team meber found
|
||||
<a href="https://github.com/chapel-lang/chapel/pull/23508"> a bug</a> in which
|
||||
a node was listed to inherit from <code>AstNode</code>, but actually inherited
|
||||
from <code>NamedDecl</code>. The <code>toNamedDecl</code> method would not
|
||||
have worked on it, even though it inherited from the class.<br>
|
||||
<br>
|
||||
Still, this pattern provides the Chapel compiler with a lot of value; I will
|
||||
show more use cases in the next subsection, like promised.
|
||||
{{< /message >}}
|
||||
{{< /dialog >}}
|
||||
|
||||
#### The Visitor Pattern without Double Dispatch
|
||||
|
||||
The Visitor Pattern is very important in general, but it's beyond ubiquitous
|
||||
for us compiler developers. It helps avoid bloating AST node classes with methods
|
||||
and state required for the various operations we perform on them. It also often
|
||||
saves us from writing AST traversal code.
|
||||
|
||||
Essentially, rather than adding each new operation (e.g. convert to string,
|
||||
compute the type, assign IDs) as methods on each AST node class, we extract
|
||||
this code into a per-operation _visitor_. This visitor is a class that has methods
|
||||
implementing the custom behavior on the AST nodes. A `visit(WhileLoop*)` method
|
||||
might be used to perform the operation on 'while' loops, and `visit(ForLoop*)` might
|
||||
do the same for 'for' loops. The AST nodes themselves only have a `traverse`
|
||||
method that accepts a visitor, whatever it may be, and calls the appropriate
|
||||
visit methods. This way, the AST node implementations remain simple and relatively
|
||||
stable.
|
||||
|
||||
As a very simple example, suppose you wanted to count the number of loops used
|
||||
in a program for an unspecified reason. You could add a `countLoops` method,
|
||||
but then you've introduced a method to the AST node API for what might be a
|
||||
one-time, throwaway operation. With the visitor pattern, you don't need to do
|
||||
that; you can just create a new class:
|
||||
|
||||
```C++
|
||||
struct MyVisitor {
|
||||
int count = 0;
|
||||
|
||||
void visit(const Loop*) { count += 1; }
|
||||
void visit(const AstNode*) { /* do nothing for other nodes */ }
|
||||
}
|
||||
|
||||
int countLoops(const AstNode* root) {
|
||||
MyVisitor visitor;
|
||||
root->traverse(visitor);
|
||||
return visitor.count;
|
||||
}
|
||||
```
|
||||
|
||||
The `traverse` method is a nice API, isn't it? It's very easy to add operations
|
||||
that work on your syntax trees, without modifying them. There is still an important
|
||||
open question, though: how does `traverse` know to call the right `visit` function?
|
||||
|
||||
If `traverse` were only defined on `AstNode*`, and it simply called `visit(this)`,
|
||||
we'd always end up calling the `AstNode` version of the `visit` function. This
|
||||
is because C++ doesn't dynamic dispatch
|
||||
{{< sidenote "right" "vtable-note" "based on the types of method arguments." >}}
|
||||
Obviously, C++ has the ability to pick the right method based on the runtime
|
||||
type of the <em>receiver</em>: that's just <code>virtual</code> functions
|
||||
and <code>vtable</code>s.
|
||||
{{< /sidenote >}}
|
||||
Statically, the call clearly accepts an `AstNode`, and nothing more specific.
|
||||
The compiler therefore picks that version of the `visit` method.
|
||||
|
||||
The "traditional" way to solve this problem in a language like C++ or Java
|
||||
is called _double dispatch_. Using our example as reference, this involves
|
||||
making _each_ AST node class have its own `traverse` method. This way,
|
||||
calls to `visit(this)` have more specific type information, and are resolved
|
||||
to the appropriate overload. But that's more boilerplate code: each new AST
|
||||
node will need to have a virtual traverse method that looks something like this:
|
||||
|
||||
```C++
|
||||
void MyNode::traverse(Visitor& v) {
|
||||
v.visit(this);
|
||||
}
|
||||
```
|
||||
|
||||
It would also require all visitors to extend from `Visitor`. So now you have:
|
||||
|
||||
* Boilerplate code on every AST node that looks the same but needs to be duplicated
|
||||
* A parent `Visitor` class that must have a `visit` method for each AST node in
|
||||
the language (so that children can override it).
|
||||
* To make it easier to write code like our `MyVisitor` above, the `visit`
|
||||
methods in the `Visitor` must be written such that `visit(ChildNode*)` calls
|
||||
`visit(ParentNode*)` by default. Otherwise, the `Loop` overload wouldn't
|
||||
have been called by the `DoWhile` overload (e.g.).
|
||||
|
||||
So there's a fair bit of tedious boilerplate, and more code to manually modify
|
||||
when adding an AST node: you have to go and adjust the `Visitor` class with
|
||||
new `visit` stub.
|
||||
|
||||
The reason all of this is necessary is that everyone (myself included) generally
|
||||
agrees that code like the following is generally a bad idea:
|
||||
|
||||
```C++
|
||||
struct AstNode {
|
||||
void traverse(Visitor& visitor) {
|
||||
if (auto forLoop = toForLoop()) {
|
||||
visitor.visit(forLoop);
|
||||
} else if (auto whileLoop = toWhileLoop()) {
|
||||
visitor.visit(whileLoop);
|
||||
} else {
|
||||
// 100 more lines like this...
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
After all, what happens when you add a new AST node? You'd still have to modify
|
||||
this list, and since everything still extends `Visitor`, you'd still need to
|
||||
add a new `visit` stub there. But what if there were no base class? Instead,
|
||||
what if `traverse` were a template?
|
||||
|
||||
```C++
|
||||
struct AstNode {
|
||||
template <typename VisitorType>
|
||||
void traverse(VisitorType& visitor) {
|
||||
if (auto forLoop = toForLoop()) {
|
||||
visitor.visit(forLoop);
|
||||
} else if (auto whileLoop = toWhileLoop()) {
|
||||
visitor.visit(whileLoop);
|
||||
} else {
|
||||
// 100 more lines like this...
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Note that this wouldn't be possible to write in C++ if `visit` were a virtual
|
||||
method; have you ever heard of a virtual template? With code like this, the
|
||||
`VisitorType` wouldn't need to define _every_ overload, as long as it had
|
||||
a version for `AstNode`. Furthermore, C++'s regular overload resolution rules
|
||||
would take care of calling the `Loop` overload if a more specific one for
|
||||
`DoWhile` didn't exist.
|
||||
|
||||
The only problem that remains is that of having a 100-line if-else (which could
|
||||
be a `switch` to little aesthetic benefit). But this is exactly where the
|
||||
X Macro pattern shines again! We already have a list of all AST node classes,
|
||||
and the code for invoking them is nearly identical. Thus, the Chapel compiler
|
||||
has a `doDispatch` function (used by `traverse`) that looks like this:
|
||||
|
||||
{{< githubsnippet "chapel-lang/chapel" "cd108338d321d0b3edf6258e0b2a58459d88a348" "frontend/include/chpl/uast/AstNode.h" "C++" 377 >}}
|
||||
static void doDispatch(const AstNode* ast, Visitor& v) {
|
||||
|
||||
switch (ast->tag()) {
|
||||
#define CONVERT(NAME) \
|
||||
case chpl::uast::asttags::NAME: \
|
||||
{ \
|
||||
v.visit((const chpl::uast::NAME*) ast); \
|
||||
return; \
|
||||
}
|
||||
|
||||
#define IGNORE(NAME) \
|
||||
case chpl::uast::asttags::NAME: \
|
||||
{ \
|
||||
CHPL_ASSERT(false && "this code should never be run"); \
|
||||
}
|
||||
|
||||
#define AST_NODE(NAME) CONVERT(NAME)
|
||||
#define AST_BEGIN_SUBCLASSES(NAME) IGNORE(START_##NAME)
|
||||
#define AST_END_SUBCLASSES(NAME) IGNORE(END_##NAME)
|
||||
|
||||
#include "chpl/uast/uast-classes-list.h"
|
||||
|
||||
IGNORE(NUM_AST_TAGS)
|
||||
IGNORE(AST_TAG_UNKNOWN)
|
||||
|
||||
#undef AST_NODE
|
||||
#undef AST_BEGIN_SUBCLASSES
|
||||
#undef AST_END_SUBCLASSES
|
||||
#undef CONVERT
|
||||
#undef IGNORE
|
||||
}
|
||||
|
||||
CHPL_ASSERT(false && "this code should never be run");
|
||||
}
|
||||
{{< /githubsnippet >}}
|
||||
|
||||
And that's it. We have automatically generated the traversal code, allowing
|
||||
us to use the visitor pattern in what I think is a very elegant way. Assuming
|
||||
a developer adding a new AST node updates the `uast-classes-list.h` header,
|
||||
the traversal logic will be auto-modified to properly handle the new node.
|
||||
|
||||
#### Generating a Python Class Hierarchy
|
||||
|
||||
This is a fun one. For a while, in my spare time, I was working on
|
||||
[Python bindings for Chapel](https://github.com/chapel-lang/chapel/tree/main/tools/chapel-py).
|
||||
These bindings are oriented towards developing language tooling: it feels much
|
||||
easier to write a language linter, auto-formatter, or maybe even a language
|
||||
server in Python rather than in C++. It's definitely much easier to use Python to
|
||||
develop throwaway scripts that work with Chapel programs, which is something
|
||||
that developers on the Chapel team tend to do quite often.
|
||||
|
||||
I decided I wanted the Python AST node class hierarchy to match the C++ version.
|
||||
This is convenient for many reasons, including being able to wrap methods on
|
||||
parent AST nodes and have them be available through child AST nodes and having
|
||||
`isinstance` work properly. It's also advantageous from the point of view
|
||||
of conceptual simplicity. However, I very much did not want to write CPython
|
||||
API code to define the many AST node classes that are available in the Chapel
|
||||
language.
|
||||
|
||||
Once again, the `uast-classes-list.h` header came into play here. With little
|
||||
effort, I was able to auto-generate `PyTypeObject`s for each AST node in the
|
||||
class hierarchy:
|
||||
|
||||
{{< githubsnippet "chapel-lang/chapel" "31a296e80cfb69bfc0c79a48d5cc9e8891f54818" "tools/chapel-py/chapel.cpp" "C++" 563 >}}
|
||||
#define DEFINE_PY_TYPE_FOR(NAME, TAG, FLAGS)\
|
||||
PyTypeObject NAME##Type = { \
|
||||
PyVarObject_HEAD_INIT(NULL, 0) \
|
||||
.tp_name = #NAME, \
|
||||
.tp_basicsize = sizeof(NAME##Object), \
|
||||
.tp_itemsize = 0, \
|
||||
.tp_flags = FLAGS, \
|
||||
.tp_doc = PyDoc_STR("A Chapel " #NAME " AST node"), \
|
||||
.tp_methods = (PyMethodDef*) PerNodeInfo<TAG>::methods, \
|
||||
.tp_base = parentTypeFor(TAG), \
|
||||
.tp_init = (initproc) NAME##Object_init, \
|
||||
.tp_new = PyType_GenericNew, \
|
||||
};
|
||||
|
||||
#define AST_NODE(NAME) DEFINE_PY_TYPE_FOR(NAME, chpl::uast::asttags::NAME, Py_TPFLAGS_DEFAULT)
|
||||
#define AST_BEGIN_SUBCLASSES(NAME) DEFINE_PY_TYPE_FOR(NAME, chpl::uast::asttags::START_##NAME, Py_TPFLAGS_BASETYPE)
|
||||
#define AST_END_SUBCLASSES(NAME)
|
||||
#include "chpl/uast/uast-classes-list.h"
|
||||
#undef AST_NODE
|
||||
#undef AST_BEGIN_SUBCLASSES
|
||||
#undef AST_END_SUBCLASSES
|
||||
{{< /githubsnippet >}}
|
||||
|
||||
You may have noticed that I snuck templates into the code above. The motivation there
|
||||
is to avoid writing out the (usually empty) Python method table for every single
|
||||
AST node. In particular, I have a template that, by default, provides an empty
|
||||
method table, which can be specialized per node to add methods when necessary.
|
||||
This detail is useful for application 3 below, but not necessary to understand
|
||||
the use of X Macros here.
|
||||
|
||||
I used the same `<` and `>` trick to generate the `parentTypeFor` each tag:
|
||||
|
||||
{{< githubsnippet "chapel-lang/chapel" "31a296e80cfb69bfc0c79a48d5cc9e8891f54818" "tools/chapel-py/chapel.cpp" "C++" 158 >}}
|
||||
static PyTypeObject* parentTypeFor(chpl::uast::asttags::AstTag tag) {
|
||||
#define AST_NODE(NAME)
|
||||
#define AST_LEAF(NAME)
|
||||
#define AST_BEGIN_SUBCLASSES(NAME)
|
||||
#define AST_END_SUBCLASSES(NAME) \
|
||||
if (tag > chpl::uast::asttags::START_##NAME && tag < chpl::uast::asttags::END_##NAME) { \
|
||||
return &NAME##Type; \
|
||||
}
|
||||
#include "chpl/uast/uast-classes-list.h"
|
||||
#include "chpl/uast/uast-classes-list.h"
|
||||
#undef AST_NODE
|
||||
#undef AST_LEAF
|
||||
#undef AST_BEGIN_SUBCLASSES
|
||||
#undef AST_END_SUBCLASSES
|
||||
return &AstNodeType;
|
||||
}
|
||||
{{< /githubsnippet >}}
|
||||
|
||||
A few more invocations of the `uast-classes-list.h` macro, and I had a working
|
||||
class hierarchy. I didn't explicitly mention any AST node at all; all was derived
|
||||
from the Chapel compiler header. This also meant that as the language changed
|
||||
and the AST class hierarchy developed, the Python bindings' code would not need
|
||||
to be updated. As long as it was compiled with an up-to-date version of the
|
||||
header, the hierarchy would match that present within the language.
|
||||
|
||||
This allows for code like the following to be written in Python:
|
||||
|
||||
```Python
|
||||
def print_decls(mod):
|
||||
"""
|
||||
Print all the things declared in this Chapel module.
|
||||
"""
|
||||
for child in mod:
|
||||
if isinstance(child, NamedDecl):
|
||||
print(child.name())
|
||||
```
|
||||
|
||||
### Application 3: CPython Method Tables and Getters
|
||||
|
||||
The Chapel Python bindings use the X Macro pattern another time, actually.
|
||||
Like I mentioned earlier, I use [template specialization](https://en.cppreference.com/w/cpp/language/template_specialization)
|
||||
to reduce the amount of boilerplate code required for declaring Python objects.
|
||||
In particular, there's a general method table declared as follows:
|
||||
|
||||
{{< githubsnippet "chapel-lang/chapel" "31a296e80cfb69bfc0c79a48d5cc9e8891f54818" "tools/chapel-py/chapel.cpp" "C++" 541 >}}
|
||||
template <chpl::uast::asttags::AstTag tag>
|
||||
struct PerNodeInfo {
|
||||
static constexpr PyMethodDef methods[] = {
|
||||
{NULL, NULL, 0, NULL} /* Sentinel */
|
||||
};
|
||||
};
|
||||
{{< /githubsnippet >}}
|
||||
|
||||
Then, when I need to add methods, I do something like the following:
|
||||
|
||||
```C++
|
||||
template <>
|
||||
struct PerNodeInfo<TheAstTag> {
|
||||
static constexpr PyMethodDef methods[] = {
|
||||
{"method_name", TheNode_method_name, METH_NOARGS, "Documentation string"},
|
||||
// ... more like the above ...
|
||||
{NULL, NULL, 0, NULL} /* Sentinel */
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
When reviewing a PR that adds more methods to the Python bindings (by
|
||||
defining new `TheNode_methodname` functions and then including them in the
|
||||
method table), I noticed that in the PR, the developer added some methods
|
||||
but forgot to put them into the respective table, leaving them unusable by
|
||||
the Python client code. This came with the additional observation that there
|
||||
was a moderate amount of duplication when declaring the C++ functions and then
|
||||
listing them in the table. The name (`method_name` in the code) occurred many
|
||||
times.
|
||||
|
||||
The developer who opened the PR suggesting using X Macros to combine the
|
||||
information (declaration of function and its use in the corresponding method table)
|
||||
into a single list. This led to the following header file:
|
||||
|
||||
{{< githubsnippet "chapel-lang/chapel" "31a296e80cfb69bfc0c79a48d5cc9e8891f54818" "tools/chapel-py/method-tables.h" "C++" 323 >}}
|
||||
CLASS_BEGIN(FnCall)
|
||||
METHOD_PROTOTYPE(FnCall, actuals, "Get the actuals of this FnCall node")
|
||||
PLAIN_GETTER(FnCall, used_square_brackets, "Check if this FnCall was made using square brackets",
|
||||
"b", return node->callUsedSquareBrackets())
|
||||
CLASS_END(FnCall)
|
||||
{{< /githubsnippet >}}
|
||||
|
||||
The `PLAIN_GETTER` macro in this case is used to define trivial getters
|
||||
(precluding the need for handling the Python-object-to-AST-node conversion,
|
||||
and other CPython-specific things), whereas the `METHOD_PROTOTYPE` is used
|
||||
to refer to methods that needed explicit implementations. With
|
||||
this, the method tables are generated as follows:
|
||||
|
||||
{{< githubsnippet "chapel-lang/chapel" "31a296e80cfb69bfc0c79a48d5cc9e8891f54818" "tools/chapel-py/method-tables.h" "C++" 548 >}}
|
||||
#define CLASS_BEGIN(TAG) \
|
||||
template <> \
|
||||
struct PerNodeInfo<chpl::uast::asttags::TAG> { \
|
||||
static constexpr PyMethodDef methods[] = {
|
||||
#define CLASS_END(TAG) \
|
||||
{NULL, NULL, 0, NULL} /* Sentinel */ \
|
||||
}; \
|
||||
};
|
||||
#define PLAIN_GETTER(NODE, NAME, DOCSTR, TYPESTR, BODY) \
|
||||
{#NAME, NODE##Object_##NAME, METH_NOARGS, DOCSTR},
|
||||
#define METHOD_PROTOTYPE(NODE, NAME, DOCSTR) \
|
||||
{#NAME, NODE##Object_##NAME, METH_NOARGS, DOCSTR},
|
||||
#include "method-tables.h"
|
||||
{{< /githubsnippet >}}
|
||||
|
||||
The `CLASS_BEGIN` generates the initial `template <>` header and the code up
|
||||
to the opening curly brace of the table definition. Then, for each method,
|
||||
`PLAIN_GETTER` and `METHOD_PROTOTYPE` generate the relevant entries. Finally,
|
||||
`CLASS_END` inserts the sentinel and the closing curly brace.
|
||||
|
||||
Another invocation of the macros in `method-tables.h` is used to generate the
|
||||
implementations of "plain getters", which is boilerplate that I won't get into
|
||||
it here, since it's pretty CPython specific.
|
||||
|
||||
### Discussion
|
||||
|
||||
I've presented to you a three applications of the pattern, in an order that happens
|
||||
to be from least to most "extreme". It's possible that some of these are
|
||||
over the line for using macros, especially for those who think of macros as
|
||||
unfortunate remnants of C++'s past. However, I think that what I've demonstrated
|
||||
demonstrates the versatility of the X Macro pattern -- feel free to apply it to
|
||||
the degree that you find appropriate.
|
||||
|
||||
The thing I like the most pattern is that the header files read quite nicely:
|
||||
you end up with a very declarative "scaffold" of what's going on. The
|
||||
`uast-classes-list.h` makes for an excellent and fairly readable reference of
|
||||
all the AST nodes in the Chapel compiler. The `method-tables.h` header provides
|
||||
a fairly concise summary of what methods are available on what (Python) AST
|
||||
node.
|
||||
|
||||
Of course, this approach is not without its drawbacks. Drawback zero is
|
||||
the heavy use of macros: to the best of my knowledge, modern C++ tends to
|
||||
discourage the usage of macros in favor of C++-specific features. Of course,
|
||||
this "pure C++" preference is applicable to variable degrees in different use
|
||||
cases and code bases; because of this, I won't count macros as (too much of)
|
||||
a drawback.
|
||||
|
||||
The more significant downside is that this approach introduces a lot of dependencies
|
||||
between source files. Any time the header changes, anything that uses any part
|
||||
of the code generated by the header must be recompiled. Thus, if you're generating
|
||||
classes, changing any one class will "taint" any code that uses _any_ of the
|
||||
generated classes. In the Chapel compiler, touching the AST class hierarchy
|
||||
requires a recompilation of all the AST nodes, and any compiler code that uses
|
||||
the AST nodes (a lot). This is because each AST node needs access to the
|
||||
`AstTag` enum, and that enum is generated from the hierarchy header.
|
||||
|
||||
That's all I have for today! Thanks for reading. I hope you got something useful
|
||||
for your day-to-day programming out of this.
|
Loading…
Reference in New Issue
Block a user