Lesson 03 / 12
Domain-Specific Languages
The same routing structure is built separately with a macro-written version and a hand-written version, shown by running it to produce the same result, and the macro version is measured for how many lines it shortens the source by; but a macro's output being readable does not bring correctness — a logically wrong path-function binding passes type checking just as easily.
Published Updated
In this lesson
- Builds the same routing structure with a macro-written version and a hand-written version and shows by running it that both produce the same result
- Measures how many lines the macro version shortens the same job by, by counting the source's own text
- Shows with an example that a readable macro version carries no guarantee of correctness, since a logically wrong path-function binding passes type checking too
Contents
The previous two lessons showed how a generated body is checked in two different setups: once through a textual substitution in the same pass, once through a program run in a separate compilation step. Neither has yet asked a question they share: why is generation preferred over writing by hand? This topic’s last lesson takes up that question. A macro can build an interface that looks, from the caller’s point of view, like a small language of its own — with its own syntax, its own shortcuts; this is called a domain-specific language. This lesson builds the same structure with two versions, verifies by running it that both produce the same result, counts how many lines the macro version shortens, and finally shows that this shortening carries no guarantee of correctness.
The term domain-specific language points here not to a broad framework but to a narrow,
concrete use: whoever reads the source can write directly in the words of the problem domain (a
path, a handler), without ever seeing the match block, function definition, or other
mechanics underneath. This interface requires no new rule from the compiler — the source that
appears after the macro expands is, as seen in the previous two lessons, indistinguishable from
ordinary Rust source. What is gained is only the way the source is written; the way it is
checked does not change at all.
This definition sets the term domain-specific language apart from its broad use in programming-language theory, where the term mostly points to an independent language with its own parser and its own runtime. Here, there is no second language — there is a single language, Rust itself, and the macro only fits that language’s syntax into a shorter pattern for a specific problem. This is why the interface built in this lesson carries no parser of its own; parsing is already done by the compiler’s own parser, as part of macro expansion.
The Same Structure, Two Versions
A routing table — a table that binds a path string to a function — can be built either with a
macro version or by hand. The macro version lists path-handler pairs like a list; the
hand-written version pours the same table into a match block’s arms. Both do the same job:
they turn a path string into a result.
The hand-written form requires repeating three things for every new path: the match block
itself, an arm’s syntax, and the presence of a catch-all arm. This repetition is not a flaw —
as seen in the Pattern Matching lesson, match demanding exhaustiveness is what keeps the
source safe. But the repeated part carries no new information beyond the path-handler pair
itself: the match, path {, and _ => -1 lines are identical in every routing table. The
macro writes this fixed pattern once, inside its own definition, and reproduces it on every
call; the caller writes only the part that changes — the pairs themselves.
// router.rs — the same routing table is built with a macro version and its hand-written equivalent.
macro_rules! build_router {
($name:ident { $($path:literal => $handler:expr),+ $(,)? }) => {
fn $name(path: &str) -> i32 {
match path {
$($path => $handler,)+
_ => -1,
}
}
};
}
fn home_page() -> i32 { 200 }
fn about_page() -> i32 { 200 }
build_router!(route_with_macro {
"/home" => home_page(),
"/about" => about_page(),
});
fn route_by_hand(path: &str) -> i32 {
match path {
"/home" => home_page(),
"/about" => about_page(),
_ => -1,
}
}
fn main() {
for path in ["/home", "/about", "/unknown"] {
let m = route_with_macro(path);
let e = route_by_hand(path);
println!("{:<12}macro={} hand={} same={}", path, m, e, m == e);
}
}
/home macro=200 hand=200 same=true /about macro=200 hand=200 same=true /unknown macro=-1 hand=-1 same=true
MM7. The macro definition’s pattern is made of three parts: $name:ident captures the name
of the function to be generated, $path:literal => $handler:expr repeats the pair one or more
times (+), and $(,)? accepts an optional trailing comma after the last pair. Together, these
three parts turn the language’s own syntax into a list-like notation: the caller does not write
match, only lines up path-handler pairs inside curly braces. All three paths (/home,
/about, /unknown) give exactly the same result in both versions; the same column verifies
this one by one.
This expressive interface was built with a declarative macro, not a procedural one — this choice is not arbitrary. As the previous lesson showed, a procedural macro has to be compiled in a separate crate, and using it requires a two-stage compilation. The routing table built here is nothing more than a repeated syntax pattern; wrapping this kind of repetition needs no hand-processing of a token stream, no separate crate, and no two-stage compilation — a declarative macro’s pattern-matching mechanism is already enough. A procedural macro is preferred when real computation is needed on the input, not just to repeat a pattern.
The generated route_with_macro function and the hand-written route_by_hand function have the
same status in the compiler’s eyes: both are an fn, both carry the same signature, both run
the same match logic. This confirms, once again, the finding of the previous two lessons — the
generated body falls into the “still holds” class — but in this lesson the real question is not
whether the body is checked, it is how much shorter writing it becomes.
This equivalence has a consequence: when choosing between the two versions, runtime behavior can
never be a criterion — both produce exactly the same thing. The only axis that decides the
choice is how the source is read and how it is written. As the number of paths grows,
this gap becomes more pronounced: if thirty paths had to be added instead of three, the
hand-written version would repeat the match arm’s syntax thirty times, while the macro version
would only add thirty pairs. This lesson does not leave that gap as intuition; it measures it
with a number.
The Source’s Own Length
There is no direct way to measure at runtime how many lines a macro actually generates —
expansion happens during compilation, and only the result ever reaches the running program’s
hands. What can be measured is the two versions’ length in the source: how many lines the
macro call itself is, and how many lines the hand-written match block doing the same job is.
These two source texts can be kept as string constants and their line counts compared.
This is a separate measurement, independent of the run in the previous section. The previous section showed that the two versions produce the same result; this section shows how much source each one does the same job with. The two complement each other: without the first, the second would be meaningless — there is no point praising a short but wrong version for being short. This is why this lesson verified the equivalence by running it before moving to the line count; the order was chosen deliberately, since the reverse would have created the impression that brevity stands in for correctness.
// line_count.rs — the same routing definition is kept in two versions, and the line counts are compared.
fn main() {
let macro_version = "build_router!(route_with_macro {\n \"/home\" => home_page(),\n \"/about\" => about_page(),\n});";
let hand_version = "fn route_by_hand(path: &str) -> i32 {\n match path {\n \"/home\" => home_page(),\n \"/about\" => about_page(),\n _ => -1,\n }\n}";
let macro_lines = macro_version.lines().count();
let hand_lines = hand_version.lines().count();
println!("macro version: {} lines", macro_lines);
println!("hand version: {} lines", hand_lines);
println!("lines saved: {}", hand_lines - macro_lines);
}
macro version: 4 lines hand version: 7 lines lines saved: 3
MM8. This measurement was set up for the same reason that explained why the --extern path
was never printed on screen in the previous lesson: there is no way to read a macro’s expansion
at runtime, so the measurement measures the one thing that can be carried into runtime — the
source’s own text. The Rust’s Design Goals lesson measured the rejection of an implicit type
conversion by the same method, comparing the character counts of two source lines; here the unit
is lines, not characters, but the method is the same: treating the source itself as data,
instead of running it.
The hand-written routing is seven lines: the function signature, the match header, two arms,
the catch-all arm, two closing braces. The macro call is four lines: the call itself, two
path-handler pairs, the closing. The three-line gap between them comes from the fact that fn,
match, and the closing braces never have to be written on the macro’s side at all — these
three lines are written once, inside the macro’s own definition, and reproduced on every
call. This ratio does not change as the number of paths grows: every new path-handler pair adds
one line to both versions, but the constant three-line gap between the two versions always stays
the same.
This fixed cost also explains why the measurement prints an absolute number, “lines saved”, rather than a ratio, “how many times shorter”. For two paths the gap is three lines, and for thirty paths the gap would still be three lines — the ratio shrinks, the absolute gap does not change. This is why the measurement prints the difference, not the ratio: under the course’s rule about numbers, a claim must carry only the quantity that was actually measured, and what can be measured here deterministically is the line gap between two fixed sources, not a generalization.
The Limiting Measurement
A readable version does not bring correctness. The macro never asks whether the path-handler
pairs the caller writes are meaningful — it only checks whether each pair is a string
constant and an expression that returns i32. This boundary adds to the measurements in the
previous two sections; it does not invalidate them: it remains true that the macro version
produces the same result and shortens the source; but these two gains carry no answer at all to
a third question — whether the table that was built is correct. The example below builds, with
the same macro, a table where a path is mistakenly bound to a function other than the one it
should be.
// wrong_but_right_type.rs — a logically wrong mapping passes type checking too.
macro_rules! build_router {
($name:ident { $($path:literal => $handler:expr),+ $(,)? }) => {
fn $name(path: &str) -> i32 {
match path {
$($path => $handler,)+
_ => -1,
}
}
};
}
fn successful() -> i32 { 200 }
build_router!(faulty_router {
"/home" => successful(),
"/error" => successful(),
});
fn main() {
println!("/error -> {}", faulty_router("/error"));
}
/error -> 200
MM9. The /error path, as its name suggests, is expected to represent a failure condition;
but the line that builds the table is mistakenly bound to the successful() function, and the
result is 200. The macro does not reject this, because it has no information with which to
reject it: the $handler:expr pattern only says “an expression returning i32 will go here”,
and never asks whether that expression is consistent with the meaning of the path it is called
for. The compiler stays silent for the same reason — successful() really does return i32,
the signature holds, and type checking passes. The generated body falls into the “still holds”
class once again, because type checking really is running; but here type checking never answers
the question “is this correct”, only the question “is this the right type”.
In this example, the same mistake could equally well have been written by hand — binding a wrong
function to an arm inside route_by_hand would have compiled the same way. The macro does not
make the mistake easier here; it just does not hide it either. The difference is that
both versions carry the same weakness, but the macro version makes that weakness reachable with
fewer lines and less writing: adding a wrong pair is an act that demands less attention than
changing an arm inside a thirty-line match block. A shorter source makes reviewing the source
easier, but it does not replace review.
This observation does not add a fifth class to the course’s four — “wrong but type-compatible” is not a separate class, it is a detail inside the “still holds” class itself. The compiler really is checking here too, skipping no step; it is only that what it checks is narrower than the question a human reading the source would ask: “is this expression of the expected type”, not “is this binding correct”.
This is a boundary that stands in the same class as the observations of the previous two lessons
but is shown from a different angle. The Declarative Macros lesson showed that the same macro
can be called with two semantically distant types, i32 and bool, that both compile, and that
the macro cannot say which one is meaningful. Here the type is fixed (i32); what changes is
which expression a string is bound to — but the result is the same: the macro operates at
the syntax level, unable to see that two different bindings are separated in meaning, seeing
only that both are formally correct. A readable version only lets the writer express their
intent more briefly; whether that intent was built correctly still has to be checked by whoever
reads it.
The distinction seen in the Procedural Macros lesson still holds here too: even if a procedural macro read its input token and performed a more complex operation on it, it would still look only at type fitness — because in Rust, type checking always runs after expansion, on the generated source, no matter which macro form is used. Even though the two macro forms (declarative, procedural) have different generation mechanisms, the source each one arrives at goes through the same checking process, and that process’s boundary is the same in both: it answers only the first of the two questions, is the type correct, is the meaning correct.
Summary
- The same routing structure was built with a macro version and a hand-written version; for three paths, both produced exactly the same result, and this equivalence was verified by running it and comparing each one individually.
- The generated body falls into the still holds class in this lesson too: the function the macro call produces is checked by the same rules as the hand-written function.
- The macro version shortened the same job by three lines; this shortening comes from the macro writing its fixed header-and-closing cost once and reusing it on every call, and the absolute gap itself does not change as the number of paths grows.
- A readable macro version brings no guarantee of correctness: a logically wrong path-function binding compiles just as well, because its type checks out.
- The only thing the macro can check is form — whether an expression is of the expected type; there is no mechanism that checks whether that expression matches the caller’s intent.
- A declarative macro was chosen to build an expressive interface; a procedural macro is preferred only when real computation on the input is needed, not to wrap a fixed pattern.
Next Step
This topic’s three lessons showed that generated source, across three separate setups — in the same file, in a separate compilation step, inside an expressive interface — always fell into the same class, still holds. The boundary that stayed constant across all three was the same: the compiler checks the generated source’s type, not its meaning. The next topic, Low-Level and Interoperability, deepens this boundary: after these three lessons in which the compiler’s checking never stopped, the next lesson enters a block where checking really does stop — and even there, checking does not shut down entirely; only a few operations open up into the “handed to the writer” class, and everything else, exactly as here, keeps on holding.
To keep your progress and take notes, Log in
My notes
Log in to take notes.