8.5.5. Macro Tutorial 5: Tag Function Macros
In tutorials 3 and 4, [function_macro] required the macro class
to live in a separate module compiled before the usage file. The
annotation needs the class to exist at parse time, so two files are
unavoidable.
daslang offers a lighter pattern — [tag_function] +
[tag_function_macro] — that lets both the tagged function and its
macro class live in the same module.
This tutorial builds a once() macro that executes a block only on
the first call. Each call site gets its own auto-generated global
boolean flag:
for (i in range(5)) {
once() {
print("runs exactly once\n")
}
print(" iteration {i}\n")
}
Output:
runs exactly once
iteration 0
iteration 1
iteration 2
iteration 3
iteration 4
8.5.5.1. Why tag functions?
[function_macro(name="X")] expects the class X to already exist
when the annotated function definition is parsed. This means the macro
class and the tagged function cannot appear in the same compilation
unit — you always need a two-file setup.
[tag_function(tag_name)] takes a different approach:
At parse time, the function is marked with a string tag. No class lookup happens yet.
During module setup,
[tag_function_macro(tag="tag_name")]scans the module for all functions carrying the matching tag and programmatically attaches the macro class as their annotation.
Because the attachment happens after both the function and the class are compiled, a single module can contain everything.
This pattern is used by many standard library modules:
Module |
Tags |
|---|---|
|
Compile-time loop unrolling |
|
Go-style |
|
Fire assertion only on first failure |
|
Promote locals to hidden globals |
|
Safe address-of operations |
|
|
In all these modules, the public function and the macro class coexist in one file — no extra “mod” module is needed.
Note
Our tutorial still uses two files because the usage file
require-s the module, which is the normal deployment pattern.
The key difference from [function_macro] is that the module
itself is self-contained — you never need a third helper file
just to define the macro class.
8.5.5.2. The module: tag_function_macro_mod.das
The module has two parts: the tagged function and the macro class.
8.5.5.2.1. Part 1 — The tagged function
[tag_function(once_tag)]
def public once(blk : block) {
invoke(blk)
}
[tag_function(once_tag)] does two things:
Records the string
"once_tag"as a tag on this function.Does not attach any macro class — that happens later.
The function body (invoke(blk)) is a fallback: it runs only if the
macro fails to transform the call for some reason. In normal operation,
every call to once() is rewritten by transform() and the
original body is never executed.
8.5.5.2.2. Part 2 — The macro class
[tag_function_macro(tag="once_tag")]
class OnceMacro : AstFunctionAnnotation {
def override transform(var call : ExprCallFunc?;
var errors : das_string) : ExpressionPtr {
// ... rewrite every call to once()
}
}
[tag_function_macro(tag="once_tag")] tells the compiler:
During module setup, find every function tagged with
once_tagand attach this class as its function annotation.
After setup, every call to once() triggers the transform()
method exactly as if we had used [function_macro].
8.5.5.3. Inside transform()
The method receives the call expression and returns a replacement AST. It proceeds in four steps.
8.5.5.3.1. Step 1 — Generate a unique flag name
let flag_name = make_unique_private_name("__once_flag", call.at)
make_unique_private_name combines the prefix with the call-site
line and column numbers, producing names like __once_flag_12_5.
Every call site gets its own name, so multiple once() calls in the
same function are completely independent.
8.5.5.3.2. Step 2 — Create the global flag
if (!compiling_module() |> add_global_private_var(flag_name, call.at) <| quote(false)) {
errors := "can't add global variable {flag_name}"
return default<ExpressionPtr>
}
add_global_private_var inserts a private bool variable (initialized
to false via quote(false)) into the module being compiled. The
variable is private, so it never leaks into the public API.
If the variable already exists (e.g., the compiler re-runs inference),
the function returns false and we report an error.
8.5.5.3.3. Step 3 — Extract the block body
var block_clone = clone_expression(call.arguments[0])
var blk = move_unquote_block(block_clone)
var stmts : array<ExpressionPtr>
for (s in blk.list) {
stmts |> push <| clone_expression(s)
}
When the user writes once() { ... }, the first argument is an
ExprMakeBlock wrapping an ExprBlock. We:
Clone the argument expression (never modify the original AST).
Unwrap the
ExprMakeBlock→ExprBlockviamove_unquote_block.Copy its statement list into a flat
array<ExpressionPtr>.
The statements array is needed because the $b() splice operator
expects array<ExpressionPtr>, not an ExprBlock directly.
8.5.5.3.4. Step 4 — Build the replacement
var replacement = qmacro_block() {
if (!$i(flag_name)) {
$i(flag_name) = true
$b(stmts)
}
}
replacement |> force_at(call.at)
return replacement
qmacro_block builds an ExprBlock using the reification
mini-language:
$i(flag_name)splices the string as an identifier reference.$b(stmts)splices the statement array into theifbody.
force_at stamps every node in the replacement with the original
call-site location so error messages point to the right place.
The final expansion of:
once() {
print("hello\n")
}
is:
if (!__once_flag_12_5) {
__once_flag_12_5 = true
print("hello\n")
}
8.5.5.4. The usage file
options gen2
require tag_function_macro_mod
def test_loop() {
for (i in range(3)) {
once() {
print("initialized (runs once)\n")
}
print(" iteration {i}\n")
}
}
def test_multiple() {
for (i in range(2)) {
once() {
print("first once (runs once)\n")
}
once() {
print("second once (runs once)\n")
}
print(" pass {i}\n")
}
}
def greet() {
once() {
print("welcome! (runs once)\n")
}
print(" greet called\n")
}
[export]
def main() {
print("--- test_loop ---\n")
test_loop()
print("\n--- test_multiple ---\n")
test_multiple()
print("\n--- test_greet ---\n")
greet()
greet()
greet()
}
test_loop — each iteration checks the flag; only the first one
fires. test_multiple — two once() calls have different
flags (different line numbers), so both fire once. greet — the
flag is global, so three separate calls still fire only once.
Full output:
--- test_loop ---
initialized (runs once)
iteration 0
iteration 1
iteration 2
--- test_multiple ---
first once (runs once)
second once (runs once)
pass 0
pass 1
--- test_greet ---
welcome! (runs once)
greet called
greet called
greet called
8.5.5.5. Key takeaways
Concept |
What it does |
|---|---|
|
Marks a function with a string tag — no class lookup at parse time |
|
During module setup, attaches the macro class to all functions with matching tag |
|
Called at every call site; returns a replacement |
|
Generates |
|
Creates a private mutable module-level variable at compile time |
|
Deep-clones an AST node (never mutate the original) |
|
Unwraps |
|
Reification: builds a block from spliced identifiers and statements |
|
Splice a string as an identifier |
|
Splice |
|
Stamps source location on all nodes |
See also
Full source:
tag_function_macro_mod.das,
05_tag_function_macro.das
Previous tutorial: Macro Tutorial 4: Advanced Function Macros
Next tutorial: Macro Tutorial 6: Structure Macros
Standard library examples:
daslib/assert_once.das (closest to our once()),
daslib/unroll.das, daslib/defer.das, daslib/static_let.das
Language reference: Macros — full macro system documentation