How to do code generation after const code?

Hello, I'm trying to create a parameterized sync - async library to remove code duplication between synchronous and asynchronous versions, while allowing to swap modes at the individual call level. However, I have run into a roadblock

My design goal is to have user space code look like this:

use lib::{sync_async, Mode::{Sync, Async}};

#[sync_async]
fn a<M: Mode>() {
    ...
}

#[sync_async]
fn b<M: Mode>() {
    a::<M>();
}

async fn main() {
    b::<Sync>();
    b::<Async>();
}

The #[sync_async] macro generates trait implementation that allows a() and b() to be called in both sync and async modes. I have already successfully implemented this step

However, the current issue is that the caller is still required to append .await. This causes the async and .await requirements to propagate up to the caller functions. I want the caller to simply type b::<Sync>() and b::<Async>(), and have the .await applied automatically when needed

To achieve this, my initial plan was to combine compile time reflection with compile time code generation that executes after the reflection step. Eg:

b::<Async>(); // Check if the generic parameter is `Sync` or `Async`. If `Sync`, generate `b();`. If `Async`, generate `b().await;`.

This would make #[sync_async] function calling another #[sync_async] function entirely portable since no .await is hardcoded. However, because macros expand before const code, this approach is impossible

Another approach I considered is having the #[sync_async] macro generate two distinct functions : name() and async name_async(). Then, a separate macro would determine which function to call based on the mode, eg: call!(Async, a())

The problem here is that macros can not handle propagated generic inputs from multiple callers. Eg:

#[sync_async]
fn a<M: Mode>() {
    ...
}

#[sync_async]
fn b<M: Mode>() {
    call!(a::<M>());
}

async fn main() {
    call!(b::<Sync>());
    call!(b::<Async>());
}

Inside fn b(), the macro can not resolve what M is (Sync or Async) at the time of expansion to decide whether to generate a() or a().await

How to do this? Is there experimental feature to do code generation after const code? Or cost code that able to do code generation?

what are the functions that you are trying to deduplicate? there is a chance that you might get them to work best with a single sync version that operates on a queue

Sync and async function. In current way, we will need to create the same code 2 times everytime we want to support both sync and async

Eg:

fn a() {
    // many lines logic
}

async fn a2() {
    // many lines logic
}

fn b() {
    // many lines logic
}

async fn b2() {
    // many lines logic
}

// etc

This will remove the need of that code duplication. All code will be sync code, and the caller is the one that decide they want to run it in sync mode or async mode. Since the mode is not hardcoded, instead it is passed via generic, we can spaw mode of this long multi functions call a() -> b() -> c() -> d() -> e() from sync to async and vice versa via 1 liner code, just by replacing the generic parameter that is passed to the top caller eg: a::() or a::(). Since it is generic parameter based, we can also change mode at invidividual code level, not global level eg the one that via feature macro

ok but what do these function do? for example are you a wrapper around some io? if that were to be the case your best approach would probably be to just have

fn before_io(){
//bulk of the code
}

fn after_io(){
//bulk of the code
}

async fn a_async(){
     before_io()
     io_async().await
     after_io()
}
fn a(){
     before_io()
     io()
     after_io()
}

That is complicated if we have many lines of this order

Non io

Io

Non io again

Io again

Non io again

Io again

I want to create the same flexibility API like in Zig's new IO API

In Zig, whether a function is run in sync or async mode is determined via what type of std::Io that is passed to the top caller, so just 1 consistent code can change between 2 mode at anytime without rewriting the body code, just need to change the parameter of the type caller, while also no code duplication happens

In your example, it will make the caller that call the async code need to hard code async and await annotation, and then it spreads to everywhere, then we can't easily change that code to sync mode without rewriting the entire caller code to call the sync code. And vice versa, it also applies to the sync code caller, it also can't change to async code without rewriting the entire caller code

To understand the benefits of this, think about rust without generic and rust with generic. Without generic, there are code duplication for multiple types. If we want to switch the callers eg a() -> b() -> c() continue, we need to change all manually becuase the callers hardcoded what function it calls. But with generic, just 1 single code. To change the callers, just need to change the generic parameter of the top caller because the children automatically receives the parameter from the top caller

This is also same like generic, because this is also a form of generic. A generic IO or a generic sync-async

The problem you are trying to solve is something that will ultimately be solved by the keyword generics initiative, but that is likely some way off and there isn't an easy way to do this in general at the moment.

One approach would be to try to do it entirely with macros, generating sync and async versions of an annotated function, and using a macro for the call site so it can pick the right one (you can even have a macro that expects to be used within an annotated function and gets replaced with the correct version of the call for sync/async). That approach isn't perfect, but may work well for what you want.

Very good to know there is already the feature to solve this in nightly

The macro approach is the approach I already did as I explained in the opening post. This approach is hard coded because macro can't know a type, so it can not be flexible, eg it can not do this

a() -> b() -> c() .... -> h(). With generic, if I pass Sync to a then b, c, d etc automatically becomes Sync. If I pass Async to a then b, c, d etc also automatically become Async

Macro can not do that, because eg:

fn a<M: Mode>()

fn b<M: Mode>()

Or

fn a(mode: Mode)

fn b(mode: Mode)

// etc

a() -> b() -> c() .... -> h()

Macro does not know what is the value of Mode that is passed from the top caller, so macro can not change from sync to async and vice versa based on 1 liner at the top caller, because macro that process fn h() does not know what value is passed to fn a(), if we pass the value from fn a() to fn h() via parameters then to the macro, macro will see it as raw syntax/token/the name of the parameter, not the value of the parameter, so we need to rechange all the macro calls one by one

yeah there is just is no way to have macro->parser->macro->parser->macro...

plus sync vs async have completely different control flow so they cannot be modeled with generics

As far as i understand it conceptually zig just always uses async. They just swap the executor.

In rust you can do the same thing. Always use async. Have an io trait that exposes all allowed functions. Then in the sync version just block in the poll function of the future your io interface returns.

The only difference is that you need await in the default case when you want to wait for completion directly, while zig has a special case for this

That way you don't even need macros just have the executor depend on the generic parameter.

that is part of the job of sync_async.
sync_async should have a way to detect the call! macros, and replace it with an await in the async function and no await in the sync function

They can be modelled with generic if there is code generation capability in const fn

Eg in the declaration level we can check if M = Sync or Async, if Async then add async keyword to the function

Then continue, if finding a caller, eg func_name::<M>() or direct func_name::<Async>(), we check if the generic parameter for that caller is Sync or Async, if Async we add .await. Then return the output, and the output become the output code

I call it manual generic monomorphization

I'm saying you should use a macro to generate the separate sync and async functions (that may seem like it is generating duplicate code in the binary, but dead code elimination will generally take care of it, and if both sync and async are used then a generics approach would generate two functions too). So you would write:

#[sync_async]
fn a() {
    // ...
    call!(b());
    // ...
}

#[sync_async]
fn b() {
    // ...
}

and it would produce:

fn a_sync() {
    // ...
    b_sync();
    // ...
}

async fn a_async() {
    // ...
    b_async().await;
    // ...
}


fn b_sync() {
    // ...
}

async fn b_async() {
    // ...
}

I want to be more that what Zig does

By removing hardcoded async and await annotation, we can truly have generic sync-async that can change with 1 liner code, that also can change multiple function call chains eg a -> b -> c -> d etc from sync ro async with 1 liner code

Manually implementing trait everytime we want to do it is also what I want to avoid, because recreating boirplate over and over. But having capability that automatically makes able to do it with just 1 plug and play is simpler

Plus it does not remove the async await callers that eventually spreads to other code, then after it becomes gigantic, we can not change that to sync without rewriting all those async and .await to sync and without .await

In this design, it will give compile time error if the parent has Sync, but the children has Async. The children must switch mode to TokioAsync that will do async operation inline, or mode TokioBlocking that will spawn to tokio worker pool

That does not address what problem I said with macro before

Eg:

a() -> b() -> c() .... -> t()

We can not change that from sync to async, and vice versa, just by changing 1 line code in a() in macro based solution. We need to change all call!(Sync, fn_name()) to call!(Async, fn_name()) manually by retyping that macro call in all that functions. So we can not swap mode easily

You can swap mode easily as the idea is that the #[sync_async] macro would know whether it is generating the sync version or the async version and adjust call!() macros accordingly. See my example above. The user wouldn't have to specify whether they want sync or async, except at the top-level.

This is like what I did but it does not allow fine grained sync - async

#[sync_async]
fn a() {
// ...
call!(b());
// ...
}

#[sync_async]
fn b() {
// ...
}

Because they are forced to be uniform at function level, not individual call level. Plus how to call the sync and async?

Eg we can not do this

#[sync_async]
fn a() {
// ...
call!(b());
// ...
}

#[sync_async]
fn b() {
// ...
}

#[sync_async]
fn c() {
call!(a())
call!(b())

// we can only have either both is sync or both is async
// not one can be sync, the other can be async

}

Plus eventually we will need to determine whether it is sync or async without keep generating duplicate code, eg in fn main

You can manually call the sync version if you want to always call the sync version, even within the async version. You could even provide a macro for it if you don't want syntax dependency.

For calling async from sync it is a little more tricky as you would need to spin up an executor. You could maybe make a macro that generates that when called from within sync (and does a normal await in async mode), but I'm not sure how useful that is.

It does not has fine grained sync - async like the generic approach

Eg:

Assume there is const code generation and then the generic approach is possible

In generic approach, we can do this

use { Mode, Mode::{Sync, Async, TokioAsyn, TokioSync} }

fn a<M: Mode>() {

}

fn b<M: Mode>() {

}

fn c<M: Mode, N: Mode>() {
    a::<M>()
    b::<N>()
}

fn d<M: Mode>() {
    c::<Sync, M>()
}

fn e<M: Mode>() {

}

fn f<M: Mode>() {
    d::<TokioAsync>()
    e::<M>()
}

fn h<M: Mode>() {
    
}

fn i<M: Mode>() {
    
}

fn g<M: Mode, N: Mode, I: Mode>() {
    f::<M>()
    h::<N>()
    i::<I>()
}

async fn main() {
    g::<TokioSync, Sync, Async>()
    g::<TokioAsync, Async, Sync>()
}

The macro can not do fine grained sync - async like that

Sync vs async isn't an implementation detail, but a completely different way of compiling and running code.

You won't be able to hide it behind a trait. A generic trait parameter only influences which code will run, not the architecture of the calling program. Swapping a function body of a sync callee is a smaller operation than rewriting the function's caller to be a suspendable fixed-stack-size state machine.

The "color" exists whether it has a syntax or not: it changes semantics of locks, lifetime of arguments and local variables, and implications of Send. It creates a difference between the program's stack and future's own state. It can't be seamless across FFI.

Rust decided to make these issues explicit instead of hiding them, because implicit compiler magic can only fix some of them. Before 1.0, Rust had "colorless" green threads, and found it implicitly affected too many OS interactions, needed all I/O patched, and got in the way of writing safe low-level code, so it got removed.

Currently the way to have both sync and async APIs is to write implementation as async only, and for the sync calls wrap it in a minimal blocking runtime, like pollster. And in case a crate is going to depend on tokio, I wouldn't even bother making a sync API, because callers can call block_on themselves, and will avoid fighting a second runtime and tokio-inside-tokio sandwich.