Cangjie macro expansion

MASAK CARL WILHELM

graduated from Uppsala University,metaprogramming domain expert.

Cangjie macro expansion

Macros are convenient function-like procedures that help generate code during the compilation of a program. Cangjie is relatively alone among modern programming languages to support macros – Rust and Swift have macros too, but not Go, Python, C#, JavaScript, or Kotlin.

In this article, we'll take a look at what happens under the hood when the Cangjie compiler finds a macro in the source code of a program it's compiling.

Tokens in, Tokens out

You can compare macros to functions: they take tokens as input and give tokens back as output. Tokens are the smallest units of the input program: individual keywords, string literals, operators, etc.

This happens as the program is compiled; but the macro is running code (a kind of "runtime" happening within the compilation), which is what gives it its strength and flexibility — you can do anything in a macro which you can do normally in Cangjie.

@LogEnterExit
func foo(x: Int64) {
    println("Got a value ${x}")
}
​
foo(42)        // prints: Entered 'foo'
               //         Got a value 42
               //         Exited 'foo'

The act of going from the macro's input to the macro's output is called "macro expansion". The word "expansion" here is a little unfortunate, because it implies that the output code is always larger than the input code. Typically it is, but not always.

compiler sends input tokens to macro which runs code, macro sends output tokens back

Macro expansion happens after parsing but before type checking; the goal of the macro expansion stage in the compiler is to find and expand all macro invocations until none of them are left.

a sequence of macro expansions happen, until there are no more macro invocations left

Annotation macros and expression macros

There are two syntaxes for making macro invocations:

  • one is on top of declarations (annotation macros, or "macros without parentheses")

  • one is as part of expressions (expression macros, or "macros with parentheses")

@AnnoMacro
class C {
}
​
println("The answer is " + @ExprMacro())

One subtle detail is that this difference is in how you invoke the macro; the macro declaration itself only cares about receiving tokens, and doesn't make a syntactic distinction between annotation macros and expression macros.

Another difference is that the annotation macros receive an input which has already been checked by the parser as a valid declaration, whereas the expression macros receive an arbitrary input, any sequence of tokens without any attached meaning. Specifically, expression macros can receive an input which isn't a valid Cangjie expression, but perhaps some domain-specific language (DSL).

A question about order

In which order should we expand our macro invocations in the program?

The correct answer is "it shouldn't matter", if the macros are pure and without side effects. In practice, macros aren't always pure; more about this later.

A "left-to-right" order is chosen as a kind of least surprise; this is similar to the order in which we evaluate the program during runtime.

Nested macros

But there's an exception to the "left-to-right" order: macro invocations can contain other macro invocations inside of their argument.

@Outer
class C {
    @Inner
    func m() {
        // ...
    }
}
​
@Outer(
    println("some Cangjie code")
    @Inner( x )
    println("some more Cangjie code")
)
​
@SqlQuery(
    SELECT * FROM MyTable WHERE @GenerateWhereClause()
)

Here we have a choice, basically a language design choice: do we handle inner/nested macro invocations first, or outer/wrapping macro invocations first? There is no right answer here: Lisp languages famously do outermost first; Cangjie and a number of other languages do innermost first.

One nice rationalization that innermost-first macro expansion mirrors the evaluation order of nested arithmetic expressions; one always evaluates the leaves first and works up the tree.

Macros that generate macro invocations

Macros can generate any code; this code can contain further macro invocations.

In this case, it might seem that questions about expansion order get raised again, but actually no; we just follow the previous rules of innermost-first, otherwise left-to-right. Of course there is already an implicit ordering if the newly generated macro invocation didn't exist a moment ago.

This is a powerful technique; in fact, one could use it create an infinite loop during compilation of a program. More usefully, it can be used to create a kind of "delegation" mechanism between macros, similar to delegation between functions.

Quoting: code talking about code

Inside of a macro, we tend to use code quotes to construct sequences of tokens. These will ultimately be returned by the macro as the expanded code.

public macro CodeQuote(input: Tokens) {
    return quote(
        println("Hi")
    )
}

We could theoretically do without code quotes, and instead manually create all the tokens we wanted using token constructors. But this would be horrible in practice. Using code quotes allows us to "show" which code we want to represent instead of having to painstakingly "tell" how to construct it.

The code quotes support interpolations, which allows us to mix "fixed" parts of our constructed code with "varying" parts, supplied as either tokens or AST fragments.

public macro Interpolation(input: Tokens) {
    return quote(
        println("before")
        $(input)
        println("after")
    )
}

A horrible mistake that was averted

The "delegation" mechanism mentioned above was originally specified in a different way: macro invocations nested in code quotes would immediately fire, invoke the macro, and be replaced with their result.

This part of the spec was clearly motivated because people realized how useful so-called "recursive macros" could be, but it was fueled by a confusion about when macro expansion should take place.

Today's mechanism is as follows: code quotes don't do any expansion, they are just inert descriptions of code. If a macro returns some tokens back to the macro expander, these tokens contain a macro invocation, the macro expander will expand the macro invocation, as usual. The mechanism is similar to asynchronously queueing up tasks for an implicit scheduler to handle.

the macro expander invokes the first macro, which returns a call to the second macro, which the macro expander then also invokes

This leads to nice and sensible slogans: Code quotes only quote. Only the macro expander expands macros.

Parallel macros

Users who used a lot of macros found that the macro expansion phase could take too long sometimes. This led to a re-examination of the idea of expansion order of macros. Might it be possible to process macros in parallel sometimes (delegating the work to multiple threads, say) instead of left-to-right?

@MacroA( ... )  // these used to expand left-to-right
@MacroB( ... )  // now they can expand in parallel

Yes, it's possible, and we changed the spec to work that way. Note that if macros are nested, we still handle them innermost first, and if a macro generates a macro invocation, we still necessarily need to handle those sequentially. But all other situations are "unordered" and can expand in parallel.

On paper, there's an expectation that macros be "pure" and without any observable side effects. In practice, sometimes macro authors resort to impure techniques in their macros, such as storing some information in a global variable. In this case, those macro users need to think like parallel programmers, and use methods and data structures which protect against data races.

Macros with context

We noticed some macro authors used nested macros (which worked well), but needed to collect information in the inner macros which the outer macros could then use. The only way to do this was to use something like package-global variables in the macro package, which felt like a weak pattern.

Therefore, we provided "macros with context", which has two major features. The first is for an inner macro to make sure that it's properly nested inside an expected outer macro. The second feature is a messaging system, where an inner macro can "send" information which the outer macro later "receives".

public macro Inner(input: Tokens): Tokens {
    AssertParentContext("Outer")
    SetItem("key1", "value1")
    SetItem("key2", "value2")
    // ...
}
​
public macro Outer(input: Tokens): Tokens {
    let messages = GetChildMessages("Inner")
    for (m in messages) {
        let value1 = m.getString("key1")
        let value2 = m.getString("key2")
        // ...
    }
}

In practice, this made us more confident that the innermost-first expansion order was the right one for Cangjie. Macro authors who wanted the outermost-first expansion order could now instead use the messaging system in "macros with context".

Even better, by nudging macro authors towards messaging, we could remove the biggest single reason they were resorting to using global variables. Of course we made the messaging internally use thread-safe data structures, which means that it's a safe default, and data races are avoided.

Macro hygiene, or the lack thereof

Advanced macro systems tend to have "macro hygiene". This takes some explaining, but it has to do with reasonable explanations about using variables in code and having them mean the same thing in the fully-expanded program as they did when you wrote them. (The "meaning" of a variable here is simply its appropriate declaration.)

// this works...
func main() {
    var x = "first"
    var y = "second"
    @swap(x, y)
    println(x)  // second
    println(y)  // first
    0
}
​
public macro swap(tokens: Tokens) {
    // pretend this works
    let (expr1, expr2) = ParseExprCommaExpr(tokens)
    return quote(
        var temp = $expr1
        $expr1 = $expr2
        $expr2 = temp
    )
}
​
// ...but this fails, due to missing macro hygiene
func main() {
    var x = "first"
    var y = "second"
    @swap(x, y)
    var temp = "temporary"  // boom! – compile error
    0
}

Cangjie's macro system does not guarantee macro hygiene in all cases. The reason for this is that we pass around identifiers (for variables) without any of the necessary context. Changing Cangjie's macro system to guarantee more hygiene, while also keeping the simplicity of the token-based handling of input and output, seems challenging.

Summary

Macros are a very powerful programming language feature. They are so powerful that sometimes the excessive power leads to problems. In Cangjie, a lot of effort has gone into providing the best of what macros can offer, while also making them safe and usable by default.

  • 8
    点赞
  • 9
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值