(I unfortunately don't have the bandwidth to actually put this through a full RFC for language inclusion, but this is a legitimate Request For Comments and proposal for improving the capability of Rust. This was written in one afternoon/evening on my smartphone; please forgive any formatting/grammar/procedural issues. A few sections have todos left to mark where I think detail is probably missing; please feel free to ask clarifying questions even/especially if they ask about the todo areas, as I'm just marking them as likely needing more clarification, not trying to defer the clarification.)
Summary
Add a new type of item, macro fn
, which is defined and used almost exactly like a function but which allows deferring name resolution and type checking to instantiation-time like a macro. This allows Rust to express the benefit of C++-style duck-typed function templates where macro_rules!
might otherwise be used while still providing a developer experience better than using C++ templates or Rust macro_rules!
for the same task.
Motivation
Wait, why are we introducing template errors into Rust? Aren't parametric generics (like Rust trait
s) which provide pre-monomorphization checks and prevent (most) post-monomorohization errors better? Didn't C++20 add concepts specifically to enable moving some template type checking forward?
Yes, but:
@Gankra_@twitter.com
say what you will, completely unprincipled trial-and-error specialization based on "did the emitted text compile?" does let you express some absolutely jacked up stuff that parametric systems balk at
@__phantomderp@twitter.com
Now, this might be blasphemy, but...I kind of want both systems to exist in one language. Mostly because even templates have structure and checks to them.
The main Achilles Heel of parametric generics is that you have to be able to express the consumed API surface with the language provided by the trait
system. Additionally, the fact that trait
s are nominally typed, requiring types to explicitly choose to implement them, while good for API evolution and ensuring semantic agreement, is still more restrictive than a structural system where any type that provides the desired API shape can be used.
Rust's trait
system continues to get better over time — GATs and initial async
in trait
s recently stabilized, and future improvements like the "keyword generics" initiative are in the pipeline — but fundamentally, trait
s will never be able to express the full gamut of what developers would like to express.
The point is not to introduce any sort of template metaprogramming to Rust, but instead to bridge the gap between generic functions and macro_rules!
. Macro functions should improve code that would otherwise be using macro_rules!
or just not get written.
It's also important to note that these are explicitly not post-monomorohization errors in the way those are typically discussed w.r.t. the current compiler. There are more details in the reference-level explanation, but in short: the errors are post-instantiation as part of type checking the caller pre-monomorphization. This instantiation may itself produce a function which still needs further monomomorphization if called from a monomorphic call site, or even require further instantiation and type inference if called from another macro function.
Guide-level explanation
(after discussing traits and generics)
Rust also offers a more powerful alternative to generic functions: macro functions. Defining a macro function uses the exact same syntax as defining a normal generic function, except that you add the macro
keyword qualifier.
macro fn add<T: Add>(lhs: T, rhs: T) -> T::Output {
lhs + rhs
}
If you compile this code, however, you will see that a warning is generated:
warning: macro function `add` does not need to be a macro
|
L | macro fn add<T: Add>(lhs: T, rhs: T) -> T::Output {
| ^^^^^ ^^^
|
= note: `#[warn(needless_macro)]` on by default
Because the signature of add
fully specifies the signature of the function, it doesn't need to be a macro function, and the compiler is telling us that. What makes a macro function more powerful than a normal generic function is that you can omit some of the type information.
macro fn add<T: Add>(lhs: T, rhs: T) -> _ {
Replacing a type in a macro function's signature means that the type is inferred. However, in this case, we still get a warning
warning: placeholder type `_` should be fully specified
|
L | macro fn add<T: Add>(lhs: T, rhs: T) -> _ {
| ^
|
= help: replace with the inferred return type: `<T as Add>::Output`
= note: `#[warn(needless_signature_inference)]` on by default
because in this case the output type is fully constrained by types that we did specify, and as such the compiler is telling us what the inferred type is so that we can specify it. This same diagnostic works with normal functions as well, although there it's an error.
If we also allow the input types to be inferred, we get a proper warning-free macro function.
macro fn add(lhs: _, rhs: _) -> _ {
In fact, this has made our function more general, as now we can use it to add two different types.
However, using type inference with macro functions isn't without a cost. Because the argument types are inferred from the use site, the macro function can't be type checked on its own. Additionally, when calling add
with arguments which don't provide the required API, the error message can no longer just reference the signature and instead needs to show an instantiation trace, making the errors much noisier.
With a generic function, the error can be reasonably clear:
error: cannot add `&str` to `&str`
|
L | add("", "");
| ^^^ no implementation for `&str + &str`
|
= help: the trait `Add` is not implemented for `&str`
note: required by a bound in `add`
|
L | fn add<T: Add>(lhs: T, rhs: T) -> T::Output {
| ^^^ required by this bound in `add`
but gets a bit more difficult with a macro function:
error: cannot call `add` with the arguments `&str, &str`
|
L | add("", "");
| ^^^ invalid instantiation
|
note: in `add`, cannot add `&str` to `&str`
|
L | macro fn add(lhs: _, rhs: _) -> _ {
| ^ ^ inferred to be `&str`
| inferred to be `&str`
L | lhs + rhs
| --- ^ --- &str
| | |
| | `+` cannot be used to concatenate two `&str` strings
| &str
|
= note: string concatenation requires an owned `String` on the left
help: create an owned `String` from a string reference
|
L | lhs.to_owned() + rhs
| ++++++++++
This is a fundamental limitation of duck typing in this way; a signature serves as a source of truth as to both what a function caller needs to provide and what the function body can rely on. With macro functions, that source of truth doesn't exist, and it could be either the caller or the function body at fault.
Because of this, normal generic functions are preferred when they can be used. This is why the compiler is eagerly emitting warnings when it can tell inference with a macro function isn't necessary. The type signatures of a function aren't just for the compiler; they're also for the humans writing and maintaining the code. The type signature serves as an important abstraction barrier, allowing the function's caller to not care about the function body, and allowing the function body to change without worry of breaking callers. Using a macro function abandons those benefits.
So when should you use macro functions, then? The primary use case is private helper functions, similarly to using closures for the same purpose, but usable in more places due to being a full namespace item rather than a value tied to an ownership scope. They're useful any time that expressing the required functionality as a trait bound is more difficult than the benefit of having it excused, and you might otherwise reach for an expression macro_rules!
macro.
Compared to macro_rules!
macros (discussed next), function macros have the benefit of being used like any other normal function. A bang!
macro can accept an arbitrary input syntax and expand to arbitrary code, evaluating its "argument" expressions in whatever order, potentially zero or multiple times, as well as being able to potentially return
or break
from its calling scope. It's also possible that a bang!
macro doesn't represent an expression, but expands to statements or items now inlined into in the calling namespace.
Macro functions, on the other hand, always behave like functions. If instantiation succeeds, calling the function is semantically exactly equivalent to calling an instantiation of any other generic function, providing all of the same predictability resulting from the structured interprocedural control flow. This includes using macro functions as values (e.g. fn()
or impl Fn()
) in combinators, which can't easily[1] be done with functionlike bang!
macros. Additionally, if some argument types aren't left to be inferred, then they're still type checked as much as possible, where caller-inferred types aren't involved.
Power-user details
Some extra user-facing details to highlight are:
- Inferred argument types must be known before calling a macro function, as when calling inherent methods, and may not rely on constraints back-propagating from lexically latter uses.
- Inferred return types must be fully concretized by the function body and may not be generic post-instantiation. If a generic return type is desired, it must be a named generic.
- A named generic can also have its interface inferred (including access to inherent items) by bounding it with an inference placeholder, e.g.
fn add<T: _>(lhs: T, rhs: T) -> T
. (Opt-out default bounds still apply.)- If such is used as a return type, it must either also be fully inferable from the argument types or specified by the caller via turbofish.
- Macro functions are instantiated on an as-needed basis, so can be significantly easier on the compiler than macro-generated trait implementations across a combinatorial set where only a small number of combinations are actually used.
- Macro functions act like generic functions w.r.t. duplication of the function body; namely, there's no guarantee that two function pointers to the same concretization are actually equivalent, but like generic functions and unlike
macro_rules!
the compiler tries to avoid unnecessary duplication before inlining. This means using macro functions instead of an equivalentmacro_rules!
can reduce the amount of IR that the compiler middle- and back-end have to process, making compilation quicker. - Macro functions can be used as methods on concrete types, but not in traits.
Reference-level explanation
(todo: more details)
Name resolution
Name resolution for parametrically bound generic types and any types determined from them is done in the defining scope, equivalently to non-macro generic functions. Name resolution for named types/items not derived from generic types is done in the defining scope. Name resolution for _
inferred types and all types derived partially from them is done exclusively in the calling scope's namespace. If the function would like to introduce traits not necessarily in scope in the calling context, it can use
them in the function body. (The use
path is resolved in the defining namespace scope.)
As they are items not associated to types, macros are resolved in the defining scope and expanded with the tokens they received there. (Notably, if a generic inferred type name is used, the macro sees the type name token, and not the name of the instantiating type.)
(todo: more details)
Implementation concerns
Name mangling macro function monomophizations directly matches that of generic functions', as they are likewise uniquely†identified by their instantiation types (modulo duplication across compilation units, which applies equally to generics so is already handled by disambiguator).
Due to how instantiation of macro functions interleaves with type checking of the caller, this relies on the compiler architecture being able to do macro function instantiation during and blocking type inference of the calling scope, rather than relying on the previously name resolved signature. This doesn't require new capabilities from the compiler, as it's no worse than the macro function's body being (hygienically) inlined, but it's worth noting.
What may require new functionality from a compiler is actually handling instantiations as items of their own. (However, this should be no more difficult than doing the same for C++ template instantiations, so function instantiation unification is something compilers are familiar with doing.) As multiple instantiations of the same macro function with the same (potentially still generic) type arguments should be treated as the same item†, the compiler needs to be able to instantiate each one only once (per compilation unit). Because generic function monomorphizations are allowed to be (and in practice are) duplicated even within the same crate, it is technically legal to instantiate macro functions uniquely for each caller, just like with generic functions, but this is generally a bad idea, and a key benefit of macro functions over macro_rules!
is not doing so unnecessarily.
Additionally, macro functions potentially interact with identifier/macro hygiene in a new way, since some identifiers are resolved in the definition scope and some in the callers'.
†Name resolution causes an important caveat to the two claims annotated above: just the instantiation types alone aren't enough to uniquely identify a macro function instantiation, as e.g. x.fmt(…)
may resolve to different functions based on traits in scope and even visibility of inherent items if x
has an inferred bound. This needs to be resolved in some manner, likely by coalescing name resolution information into a disambiguator hash.
As with the example in the guide-level section, errors resulting from instantiation should display the context stack of what caused the error. However, any suggestions in the stack should not be considered machine-applicable, as they may break other instantiations. Suggestions for changing code outside the current workspace should be suppressed.
Variants, alternatives, and extensions
- The closest current alternative is using a closure calling a
functionlike!
macro. This has the advantage of already being stable, and matches the post-expansion semantics very closely, but type inference is impeded by the unanotated closure arguments, whereas a major point of macro functions is to provide access to inherent name resolution.- If a
typeof!($expr)
built-in were to be provided to surface-level code (even if perma-unstable), this could be prototyped decently well externally by a macro wrapping the IIFE pattern. A proc macro doing simple syntactic dataflow analysis could even partially provide the partial pre-instantiation type checking available to macro functions.
- If a
- Inference of the return type is deliberately very restricted to limit the scope of the newly introduced inference. This may be reasonable to relax.
- No semantics have been provided for what a
T: Trait + _
bound means. There are multiple options, each with some merit:- forbid this, and say types must either be exclusively inferred or completely parametrically bound;
- allow this and apply the parametric bound as a requirement on the caller pre-instantiation like other generic bounds, name resolving and type checking uses of the trait at the declaration site, preventing trait items from being shadowed by later instantiation; or
- allow this and apply the parametric bound as a requirement, but defer name resolution and type checking until after instantiation, allowing trait items to be shadowed by inherent ones.
- Generalizing over function qualifiers (e.g.
const
) is not provided for beyond what's available parametrically. A notable feature of C++ templates not provided by macro functions is thatconstexpr
can be used on templated functions with it being dropped from the instantiation if it does something non-constexpr
.- This may result in a funny state where it's possible to be
const
-generic over parametric functionality with~const
but not over inferred functionality. Ideally, whatever solution applies to trait bounds should also work for the_
inference type/bound.
- This may result in a funny state where it's possible to be
- Macro functions in traits are conservatively not allowed, but could be fairly easily (although obviously not object safe).
- It may be desirable to restrict macro functions from being (reachable)
pub
, due to the significant semver hazard and crate-localized inference being more agreeable than cross-crate. A warning or perhaps even deny lint certainly seems reasonable (machine-applicable fix:pub(crate)
), but even a hard error can be justified.
Importantly, all semantics of macro functions should be limited to _
-bound types, such that their existence does not restrict the evolution of standard parametric generic functions. Notably, this means that macro functions should not allow for variable argument counts until parameter packs for generic functions are decided on. Depending on the semantics decided on for generic function parameter packs (assuming they happen), macro functions may not even need to extend parameter packs beyond the obvious of allowing an _
inference bound to be used. The same goes for optional/default arguments and most other extensions to function syntax that bang!
macros are sometimes used to emulate.
Adding macro fn
leads the question about other items, like macro impl
or macro struct
. This RFC does not consider those beneficial like macro fn
. Importantly, macro fn
is still primarily a repackaging of functionlike!
macros which can offer some extra niceties due to being more regular, whereas macro impl
or macro struct
would significantly change what can be expressed, and as such run a much larger risk of significantly degrading the average developer experience of using Rust.
Drawbacks and mitigations
All of the concerns around clarity of post-monomorohization errors apply. This RFC attempts to specify macro function instantiation such that most of the concerns are mitigated, but the most visible problems with C++-style template errors are fundamental and apply just as much to macro functions:
- Errors caused by instantiation have to provide a stack of "in the expansion of" for each level of nesting in order to communicate what the error is. This is partially mitigated by the availability and encouragement to use
trait
s and generics instead, as well as the ability for macro functions to be partially parametric, with that part checked at the definition. Additionally, we can elide intermediate frames (similarly to how overly large types are handled, putting them in a details file) as likely irrelevant; this is more likely to be true than with C++ templates since macro functions deliberately have no equivalent - Instantiating a template results in a potentially significant chunk of code getting type checked, and potentially multiple cascading errors. This is unfortunately just an exercise in building good diagnostics which provide the correct amount of context without being overwhelming. The only mitigation Rust provides is the same encouragement to prefer parametric API wherever possible.
- It's not clear from the signature that gets included in documentation what the requirements are to call a macro function. This is a fundamental property, but at least macro functions are slightly better than
functionlike!
macros in this regard.
The analogy to C++ templates opens the path to more requests to support C++ template/concept/constexpr if
style metaprogramming. This RFC explicitly does not advocate for such—in fact, explicitly advocates for making more patterns which want it expressible parametrically instead of cfg
and macros—but it certainly moves in the direction of that looking reasonable.
Macro functions introduce a new logical step to compiling (macro function "instantiation") which has to be implemented and learned alongside the other transformation steps (e.g. macro "expansion" and generic "monomorphization"). How differentiated this step is is unclear; e.g. the process of concretizing/monomorphizing generics is also sometimes refered to as instantiation.
Overuse of macro functions could significantly adversely impact type checking times, as it becomes significantly easier to end up with inference through a lot of code. The restrictions on inference flow through macro functions is intended to help mitigate this risk. Additionally, it should be possible to provide a machine applicable refactoring to turn a macro function into a nonmacro (potentially generic) function if it's known to only be used from a single call site. (This should probably be clippy or rust-analyzer's domain rather than rustc's.)
One important nonobvious non-drawback to highlight: it is in fact possible to wrap around / abstract away a macro function with a normal generic function, just the same as you can do for a functionlike!
expression macro. If you can satisfy the inferred API of the macro function via trait bounds, then you can use the macro function from a nonmacro function, and the instantiation will use the API you've available.
(Wow I wrote more than expected. This is significantly lagging the Android Chrome text editor, even.)
It is possible to turn a functionlike expression macro into a proper function value by wrapping it in a closure, e.g.
|a, b| m!(a, b)
. ↩︎