Notes
2025/08/05
Building on last week's paper and a discussion at the programming languages group at the Recurse Center, here's a slightly different syntax for reasonable macros.
The basic idea is the same: If we want language "constructs" such as =
to be normal functions, we need to distinguish variables that are being bound from variables that are being used.
x = y // x is being bound, y is being used
Since variables are used more often than they are being bound, it seems natural to use special syntax for binding variables:
:x = y // x is being bound, y is being used
In terms of scope, :x
is being bound in the enclosing scope: If we delimit variable scopes using {...}
, we can think of :x
as being bound in the enclosing {...}
, so that any later occurrence of x
in the block gets its value from the binding:
{
f(x) // x gets its value from the parent scope
:x = y
f(x) // x gets its value from `:x = y`
}
The top-level {...}
is implicit. Using explicit bindings, a pattern matching construct could look as follows:
:a = "foo"
:b = Pair("foo", "bar")
match (b) [
Pair(:x, :x) -> { "the same" }
Pair(a, :y) -> { "first has value a" }
Pair(:x, :y) -> { "different" }
]
But frequently, variables need to be bound in several different scopes at once. The classic example is recursive functions: The name of a recursive function needs to be bound both in the definition of the function (so that it can be called recursively) and in the enclosing scope (so that the function can be used). In contrast, the parameters of a recursive function are only bound in the definition of the function, since it wouldn't make sense to refer to the parameters of a function after the function definition ends.
One way to make this difference syntactically explicit is to use the syntax :x
for a variable that is bound (only) in the next scope, while ::x
is used for a variable that is bound in the next two scopes (which can also include the enclosing scope). A recursive function could then be defined as follows:
::foo(:x, :y) = {
foo(f(x), f(y)) // foo is called recursively
}
foo(1, 2) // foo is used
This is the solution presented in the above paper. It works, but isn't very pretty.
An alternative would be to use different syntax for binding something in the enclosing scope compared to inner scopes. For binding something in the enclosing scope, we can continue to use :x
, but as soon as a function (which could be an infix function such as =
) has a block argument of the form {...}
, we assume that a variable x
is a binding and that a variable that is being used must be prefixed with an explicit "pin" as ^x
:
:foo(x, y) = {
foo(f(x), f(y)) // foo is called recursively
}
foo(1, 2) // foo is used
The pattern matching construct from above would look as follows:
:a = "foo"
:b = Pair("foo", "bar")
match (b) [
Pair(x, x) -> { "the same" }
Pair(^a, y) -> { "first has value a" }
Pair(x, y) -> { "different" }
]
This would be compatible with the macro system described in the paper (and it would still be possible to desugar macros without requiring macro expansion), while also being much closer to how variable scopes work in most other languages. (The ^
notation is also what Elixir uses to resolve variables in patterns.)
There's also the question of how functions are distinguished from macros. Function arguments are passed as regular values, but macro arguments are annotated in such a way that their syntactic structure is exposed. The solution presented in the paper is to use different syntax when macros/functions are defined so that the environment keeps track of which symbols are functions and which are macros.
:f = ... // regular function/variable
#f = ... // macro
Another option would be to decide whether a function is a macro based on how it's being used, based on the following logic: A function is a macro if it is called with {...}
blocks as arguments or if it is used inside of an enclosing {...}
block and one of its arguments contains an explicit binding:
f(x, y) // regular function
f(:x) // macro with explicit binding argument
f(x, { ... }) // macro with block argument
f(g({ ... })) // f is a function, g is a macro
f(g(:x)) // f is a macro, g is not called
These changes (distinguishing enclosing scopes from inner scopes, resolving macros based on how functions are called) preserve the essential aspects of reasonable macros, but lead to syntax that is much closer to existing languages.