How to write function-like procedural macro for enums

Basically, I need the following function call to be evaluated at compile-time:

println!("{}", event_code(Event::B));

Function definition:

fn event_code(event: Event) -> EventCode {
  match event {
    Event::A => EventCode::One,
    Event::B => EventCode::Two,
  }
}

Here are the enums used above:

enum Event {
  A,
  B,
}

enum EventCode {
  One,
  Two,
}

I tried using const fn, but after looking into the generated assembly code, I can see call statements to the function in question, which I guess is because of calling the function in a non-constant context. My question is how can I write a simple macro that executes the fn event_code(event: Event) -> EventCode at compile-time? I'm new to macro system, but I think procedural macros are my way to go. I've read the Rust Book Reference on Macros, and other blog posts, but I couldn't find an example similar to what I want to achieve, although it seems a simple task in my opinion. Am I going in the wrong direction by trying to achieve this using procedural macros? If not, I can see procedural macros mainly a 3-steps process - at least for function-like macros; parsing the input TokenStream (e.g., by using syn crate), do something with the parsed stream, and generate output code as TokenStream (e.g,. by using quote crate). I'm kind of puzzled with how can I convert the input TokenStream, which should be a Event variant, and if it's not return an error saying so, then use it as an argument for event_code function, although being a stream of tokens, not an Event enum type?

My goal is to achieve something similar to this:

let code = event_code!(Event::B);
// should expand to:
let code = EventCode::Two;

Edit 1: Fixed some typos
Edit 2: Fixed some typos
Edit 3: Mention helper crates
Edit 4: Fixed some typos

You can actually use const fn you just have to put the value in a const before you use it

1 Like

If you know a value at compile time and want an operation on that value to also occur at compile time, you can use a const instead of let.

const event: Event = Event::B;
const code: EventCode = event_code(event);

Using procedural macros here seems unnecessarily complicated.

Also, the compiler will almost certainly inline event_code() into the caller because it's practically a no-op[1]. When the Event is known at compile time, LLVM's constant folding pass will evaluate the match at compile time.


  1. The corresponding Events and EventCodes match up, so the compiler just reads the Event's discriminant into a register then returns it as the EventCode discriminant.

    event_code:
      movl	%edi, %eax
      retq
    

    (playground - click "Show Assembly") ↩︎

3 Likes

Thank you @semicoleon @Michael-F-Bryan for the quick reply.
Will those optimization also affect the following use case, where there's no const variable binding?

println!("{}", event_code(Event::B));

Will it be replaced with:

println!("{}", EventCode::Two);

by the compiler during optimization?

Yes

2 Likes

So it seems using procedural macros for this use case is unnecessary optimization.

Closing this thread.

Thank you all!

@Michael-F-Bryan, in case I need to write a procedural macro to achieve this, can you please point me to a direction, or share some links to posts/examples as a starting point? To put simply, I just want to execute the mapper function at compile-time. I read somewhere that proc-macros run before Rust type system or something like that. Is that right? Does it have any effects on my use case?

David Tolnay's Proc Macro Workshop gives you a nice, gradual introduction to proc macros. It goes through several scenarios (function-like, derive, etc.) with a test you can use to check each step in the implementation process.

Structuring, testing and debugging procedural macro crates from Ferrous Systems walks you through the higher-level thinking of structuring/testing your proc macros.

2 Likes

Thank you!
I tried implementing the solution using const fns, and compared benchmarks of it with my previous implementation, which was more static and required more manual maintenance work. Using const fn was >10x slower than my previous implementation, although they behave similarly, and IMO both of them would resolve to an identical code at compile-time. That's why I'm trying to implement it using proc-macros. What is your suggestions on this? Do you see my use case something that can be done with fn-like proc-macros?

How are you measuring this?

Assuming it doesn't get optimised away completely, the entire function call should be 3 or 4 instructions at most. For trivial operations like this, it's almost impossible to correctly benchmark the code because overheads from things like measuring time and looping can often be greater than what you are trying to measure.

Benchmarking tools like criterion will also often use a "black box" when you do parameterized tests to make sure the compiler can't do any of the optimisations we're looking for.

I'm also assuming you are doing everything with --release, otherwise there's no point going any further.

The only way to check this is by reading the assembly yourself.

Given the event_code() example in your initial post and this godbolt, I would be very surprised if the const fn version is more than 2 instructions.

event_code:
  movl	%edi, %eax
  retq

If it's any more, there's a good chance you're doing something more complex than what you originally posted and we're looking at something entirely different.

By this point it's also worth asking yourself some questions,

  1. Have you unconsciously decided procedural macros are the right solution and set up the scenario so any other solutions aren't suitable for whatever reason? (i.e. is this an X-Y problem?)
  2. Is it worth spending several more hours trying to optimise a function which will only take a handful of instructions to execute, anyway?
1 Like

I'm using cargo bench in release mode + criterion, plus wrapping all the test data in a black_box.

In my actual use case each enum contains more that 150 variants, and I have 3 functions with match expressions in each, similar to the example I posted. But, they all are simple mappings from a variant of one enum to another, and there's no more computation and processing in any of them.

Edit: I will post the actual code in a gist shortly.

I think it is worth pointing out that this only happens due to an optimization, and if evaluating event_code at compile-time is expensive, then there's a good chance that this optimization does not happen.

This is in contrast to what happens with

const code: EventCode = event_code(event);

Here, the compiler will always evaluate the expression at compile time and insert the resulting value anywhere you use it, so if you use a const, then you are not relying on a compiler optimization to get the compiler to replace it.

Of course, for a function like event_code, this is unlikely to make a difference, but I think that this is an important distinction to make.

2 Likes

It can easily make quite significant difference.

Huge matches like these look, well… huge to the compiler thus they can easily make something like event_code huge to the optimizer and then it affects inlining really badly.

C++ has consteval to solve that issue, maybe Rust needs something like that, too.

Or you can force it with something like this:

  println!("{}", {const tmp: EventCode = event_code(event); tmp});

Should be possible to turn that into a regular (non-proc) macro, but you would have to specify type, too.

Topicstarter's expectation that procmacros are all-powerful, unfortunately, is unjustified: proc macro, like any macro, is executed before things like types, enums, functions and so on even exist. Thus answer to the naive questions which started this thread are the following:

  1. Do you consider something which may become a minimum a conference talk worthy item and maximum couple of Phds “simple” or “trivial”?
  2. The problem of “how would I convert something into a Enum variant to call event_code” is not all that problematic. More interesting would be a discussion about how your proc macro would get hold on the types and functions (things that not supposed to even exist during its execution)!

And while discussing about how one may radically redesign Rust to make that possible and then how to add that redesigned Rust to the procmacro and then how can you hijack data from the actual Rust compiler… they all sound very intriguing and interesting but remind me about joke of removal of tonsils with access through anus (allegedly a thoroughly Russian achievement): not, strictly speaking, impossible but… is it actually feasible?