Why println! is a macro¶
Level: 201 · working knowledge
One line: A function gets its format string only when the program runs, so it cannot see your variables or check its arguments against the string; println! is a macro so that the compiler reads the string first, and a missing argument, an extra one, an unknown {name} or a type with no Display stops the build, with an error that points inside the string.
Two calls, and what they became¶
//! Two calls to `println!`, and nothing else, so that the expansion fits on
//! the page.
fn main() {
let name = "Ada";
let items = 3;
println!("{name} has {items} items in the cart"); // Ada has 3 items in the cart
println!("{} bought {:?}", name, ["tea", "milk"]); // Ada bought ["tea", "milk"]
}
Verified output of cargo run -q -p println_app — declared in cargo_runs.toml and regenerated by tools/run_cargo_demos.py, never hand-typed.
cargo expand shows what the macros became. It is a separate install; the key below was recorded with the command it runs underneath, cargo rustc -- -Zunpretty=expanded:
Verified output of cargo rustc -q -p println_app --profile=check -- -Zunpretty=expanded — declared in cargo_runs.toml and regenerated by tools/run_cargo_demos.py, never hand-typed.
#![feature(prelude_import)]
//! Two calls to `println!`, and nothing else, so that the expansion fits on
//! the page.
extern crate std;
#[prelude_import]
use std::prelude::rust_2024::*;
fn main() {
let name = "Ada";
let items = 3; // Ada has 3 items in the cart
// Ada bought ["tea", "milk"]
{
::std::io::_print(format_args!("{0} has {1} items in the cart\n",
name, items));
};
{
::std::io::_print(format_args!("{0} bought {1:?}\n", name,
["tea", "milk"]));
};
}
Three things happened before any type checking:
println!is gone. Each call became a block calling::std::io::_printwith one argument. That function takes a singlefmt::Arguments↗; building that one value, checked, is the macro's whole job.println!itself is amacro_rules!macro over the built-informat_args_nl!, as Three kinds of procedural macro shows from std's source.- The
\nwas appended to the string. That is the_nlinformat_args_nl!. {name}became{0}, andnamebecame an argument. The capture was turned into an ordinary positional argument while the string was read, so from here on the compiler checksnameas a variable like any other. Whatformat_args!becomes after this, with the borrows made explicit, is on Printing the HIR.
The trailing comments moved to the wrong lines: the printer keeps comments but not their positions (the same trap on the HIR).
The same thing as a function¶
Here is the best a function can do: take the template as a &str, and every argument as a trait object, since a slice holds one type.
//! `format!` written as an ordinary function. It compiles, and each mistake the
//! macro would refuse is found only when the line runs, if at all.
use std::fmt::Display;
/// Replaces each `{}` in `template` with the next argument.
///
/// The template is a `&str` like any other, so it is read while the program
/// runs. The arguments have to share one type, so every one is a `&dyn Display`.
fn format_function(template: &str, args: &[&dyn Display]) -> Result<String, String> {
let holes = template.matches("{}").count();
if holes != args.len() {
return Err(format!("expected {holes} arguments, got {}", args.len()));
}
let mut out = String::new();
let mut rest = template;
for arg in args {
let (before, after) = rest.split_once("{}").expect("counted above");
out.push_str(before);
out.push_str(&arg.to_string());
rest = after;
}
out.push_str(rest);
Ok(out)
}
fn main() {
let name = "Ada";
let items = 3;
let right = format_function("{} has {} items in the cart", &[&name, &items]);
let one_short = format_function("{} has {} items in the cart", &[&name]);
let by_name = format_function("{name} has {items} items in the cart", &[]);
println!("{right:?}");
println!("{one_short:?}");
println!("{by_name:?}");
}
Verified output of cargo run -q -p as_a_function — declared in cargo_runs.toml and regenerated by tools/run_cargo_demos.py, never hand-typed.
Ok("Ada has 3 items in the cart")
Err("expected 2 arguments, got 1")
Ok("{name} has {items} items in the cart")
It compiles, all three calls. What that costs:
- A mistake is a run-time value.
one_shortis anErrthat someone has to handle, on every call, forever, for a mistake that was visible in the source. The macro version of it does not build (below). {name}cannot work. A function receives a string, not the caller's scope; there is nothing it could looknameup in. Soby_nameis not even an error: it comes backOk, braces and all.- One trait for every argument.
&[&dyn Display]has no room for the second call inprintln_app, where one hole is{}and the other{:?}: those needDisplayfrom one argument andDebugfrom the other. - The template is read on every call.
format_args!splits the string once, while compiling;format_functionsearches it withmatchesandsplit_onceeach time it runs.
What the compiler refuses¶
Each of these is a small binary of its own in the println_refusals package, and each error quotes the line. The first two come from format_args! itself, while it expands:
Verified output of cargo build -q -p println_refusals --bin missing_argument, which fails on purpose — declared in cargo_runs.toml and regenerated by tools/run_cargo_demos.py, never hand-typed.
error: 2 positional arguments in format string, but there is 1 argument
--> println_refusals/src/bin/missing_argument.rs:3:15
|
3 | println!("{} has {} items in the cart", name);
| ^^ ^^ ----
error: could not compile `println_refusals` (bin "missing_argument") due to 1 previous error
Verified output of cargo build -q -p println_refusals --bin extra_argument, which fails on purpose — declared in cargo_runs.toml and regenerated by tools/run_cargo_demos.py, never hand-typed.
error: argument never used
--> println_refusals/src/bin/extra_argument.rs:3:48
|
3 | println!("{} has items in the cart", name, items);
| -------------------------- ^^^^^ argument never used
| |
| formatting specifier missing
error: could not compile `println_refusals` (bin "extra_argument") due to 1 previous error
The next two are not format_args! errors at all. The macro accepted the string and turned {nmae} and {cart} into arguments, as the expansion above did with {name}; name resolution then fails to find nmae, and the type checker finds no Display for Cart. Both still underline text inside the string — nmae for the first, all of {cart} for the second — because the arguments the macro created carry spans that point there:
Verified output of cargo build -q -p println_refusals --bin unknown_name, which fails on purpose — declared in cargo_runs.toml and regenerated by tools/run_cargo_demos.py, never hand-typed.
error[E0425]: cannot find value `nmae` in this scope
--> println_refusals/src/bin/unknown_name.rs:3:16
|
3 | println!("{nmae} has {items} items in the cart");
| ^^^^
|
help: a local variable with a similar name exists
|
3 - println!("{nmae} has {items} items in the cart");
3 + println!("{name} has {items} items in the cart");
|
For more information about this error, try `rustc --explain E0425`.
error: could not compile `println_refusals` (bin "unknown_name") due to 1 previous error
Verified output of cargo build -q -p println_refusals --bin not_display, which fails on purpose — declared in cargo_runs.toml and regenerated by tools/run_cargo_demos.py, never hand-typed.
error[E0277]: `Cart` doesn't implement `std::fmt::Display`
--> println_refusals/src/bin/not_display.rs:7:15
|
7 | println!("{cart} has {} items", cart.items);
| ^^^^^^ `Cart` cannot be formatted with the default formatter
|
help: the trait `std::fmt::Display` is not implemented for `Cart`
--> println_refusals/src/bin/not_display.rs:1:1
|
1 | struct Cart {
| ^^^^^^^^^^^
= note: in format strings you may be able to use `{:?}` (or {:#?} for pretty-print) instead
For more information about this error, try `rustc --explain E0277`.
error: could not compile `println_refusals` (bin "not_display") due to 1 previous error
The mistakes that stop the macro from reading the string — {n + 1}, {self.name}, a format string held in a variable — are on The braces take a name, from the user's side.
Checking a literal is not reserved to the compiler¶
A function-like procedural macro receives its input as tokens too, a string literal among them, so it can read the string while compiling and refuse it with compile_error! or a syn::Error. A routes! macro does exactly that with "/users/{id}", checking each {param} against the handler the way format_args! checks {name} against the arguments.
One thing it cannot copy on stable Rust: the underline inside the string. Pointing at part of a literal needs Literal::subspan ↗, which in 1.98.0's proc_macro is still #[unstable(feature = "proc_macro_span", issue = "54725")], and proc-macro2's wrapper documents that it "will always return None" off nightly. A procedural macro's error underlines the whole literal; format_args!, being part of the compiler, underlines {nmae}.
If you are coming from another language¶
- C.
printfis a variadic function: the format string is read at run time, and nothing in the language ties%dto an argument. Compilers bolt a check on anyway — Apple clang 21 warns "more '%' conversions than data arguments" forprintf("%s has %d items in the cart\n", name), by default — but it is a warning, the program builds, and one run of it here printedAda has -1084071784 items in the cart. Reading a missing vararg is undefined behaviour, so there is no promise even of that. In Rust the same line does not compile. - C++. C++20's
std::formatis the closest match: itsbasic_format_stringconstructor isconsteval, so with a literal the string is parsed at compile time andstd::format("{} has {} items", "Ada")fails to compile (checked with Apple clang 21 and-std=c++20). The error arrives as "call to consteval function … is not a constant expression" with a note from inside libc++, rather than a sentence about arguments. There is no capture by name. - Python. An f-string looks like
{name}capture and is parsed when the module is compiled, but names are looked up when the line runs:f"{nmae} has items"inside a function nobody calls raises nothing, and calling it raisesNameError."{} has {} items".format(name)fails at run time withIndexError: Replacement index 1 out of range for positional args tuple(Python 3.14). Rust's{name}is resolved while compiling, like any other use ofname. - Java.
String.format("%s has %d items", "Ada")compiles and throwsjava.util.MissingFormatArgumentException: Format specifier '%d'when it runs (OpenJDK 25).javacdoes not look inside the string. - ABAP. (Not machine-checked — CI cannot run ABAP.) A string template
|{ name } has { items } items|embeds the expressions themselves, so there is no count to get wrong and a misspelt name is a syntax error, like Rust's capture.MESSAGEwith&1…&4placeholders is theprintfside: nothing checks that theWITHvalues match the text of the message.
See also¶
- The braces take a name — the format string from the user's side: what may go between the braces, and why it must be a literal
- What
dbg!does — anothermacro_rules!macro that needs to be one: it prints the source text of its argument - Macros — what the
!means at a call site - Printing the HIR — what
format_args!turns into after expansion - Three kinds of procedural macro — built-in macros such as
format_args!, and the procedural ones a crate can add - A
routes!macro — a procedural macro that checks{param}in a string literal while compiling std::fmt↗ — the format string syntax in full
Po polsku¶
println! nie może być zwykłą funkcją, bo funkcja dostaje łańcuch formatujący (format string) dopiero w czasie działania programu. Nie zna wtedy zmiennych wywołującego, więc {name} nie ma gdzie wyszukać, a liczba i typy argumentów muszą być ustalone w sygnaturze — najlepsze, co się da zrobić, to wycinek &[&dyn Display], jedna cecha dla wszystkich. Pomyłka zostaje wartością Err sprawdzaną przy każdym wywołaniu. Makro działa wcześniej: println! to macro_rules! nad wbudowanym format_args_nl!, a rozwinięcie pokazuje, że {name} zamienia się w zwykły argument pozycyjny, a na końcu łańcucha pojawia się \n.
Błędy przychodzą z dwóch miejsc. Zła liczba argumentów to komunikat samego format_args!, jeszcze podczas rozwijania makra. Nieznana nazwa (E0425) i brak Display (E0277) to już zwykłe błędy rozwiązywania nazw i sprawdzania typów, ale podkreślenie i tak wskazuje fragment wewnątrz literału. Własne makro proceduralne też może sprawdzić literał w czasie kompilacji, jednak na stabilnym Ruście podkreśli cały literał, bo Literal::subspan jest wciąż niestabilne.
Szukaj po polsku: dlaczego println! jest makrem · format_args! w Ruście · rust format string compile time check · rust println macro expansion · rust variadic functions