How can I cut down on the enum boilerplate here?

I've got code that looks like this (renamed & simplified):

trait CoreTask {
    type Dependencies;
    type Output;
    
    fn execute_with(self, dependencies: Self::Dependencies) -> impl Future<Output = Self::Output>;
}

mod core_tasks {
    pub struct CreateFoo { /* ... */ }
    impl CoreTask for CreateFoo { /* ... */ }
    
    pub struct GetBars { /* ... */ }
    impl CoreTask for CreateFoo { /* ... */ }
    
    // ...
}

pub struct UiTask<T: CoreTask, Message> {
    core_task: T,
    to_message: Box<dyn Fn(T::Output) -> Message>,
}

pub enum Message { /* ... */ }

pub enum Task {
    CreateFoo(UiTask<core_tasks::CreateFoo, Message>),
    GetBars(UiTask<core_tasks::GetBars, Message>),
    // ...
}

fn do_some_work() {
    let task: Task = get_task();
    
    match task {
        CreateFoo(UiTask { core_task, to_message }) => /* ... */,
        GetBars(UiTask { core_task, to_message }) => /* ... */,
        // ...
}

As you can see, there's a lot of boilerplate. Unless there's a way to remove the generic parameter T from UiTask and use a trait object for the core_task field—and after two days of headaches, I am forced to admit there isn't—I must use an enum.

Is there a way to cut down on the boilerplate? I found the enum_dispatch and enum_delegate crates, but they work on enums whose variant stores implementors of a trait, whereas UiStruct is more than that (and the enum can't implement the trait regardless, because associated types of CoreTask differ across variants).

The match_any was helpful in cutting down on the final match statement by letting me treat the returned task as if it were a trait object, but that leaves the rest of the boilerplate untouched, and it is not itself without drawbacks, as rust-analyzer breaks inside the macro.

Is it hopeless?

There are some things you could do:

  • Make core_tasks an enum (CoreTasks)
  • Remove the Task enum, and instead match on the CoreTasks enum

The Task enum duplicates the structs in the core_task module. (Basically what @firebits.io said). While they suggest inverting the enum-of-structs to struct-of-enum, another option is dynamic dispatch with a trait object:

pub struct UiTask<T: CoreTask, Message> {
    core_task: Box<dyn CoreTask>,
    to_message: Box<dyn Fn(T::Output) -> Message>,
}

This makes UiTask the primary interface, and any introspection of T (which may not be necessary) occurs through the CoreTask trait.

I considered turning CoreTask into an enum, but the problem is that for each task, its handler function needs to return a different type. The associated type, Output, exists for that reason.

impl CoreApplication {
    pub fn execute<'app, T: CoreTask>(
        &'app mut self,
        task: T,
    ) -> impl Future<Output = <T as CoreTask>::Output>
    where
        <T as CoreTask>::Dependencies: From<&'app mut Self>,
    {
        let dependencies = self.into();
        task.execute_with(dependencies)
    }
}

Could this be adapted to work with an enum?


Edit: Sorry, I just realized you were talking about the core_tasks field, not the CoreTask trait. But I think the Output assoc. trait is still going be a problem, since the to_message field depends on it.

I don't think that's going to compile, since the associated types of CoreTask need to be specified when turning it into a trait object, which only replaces one generic parameter, T, with two others.

If it's possible to erase the associated types and treat CoreTask—or something like it—as a trait object, I could downcast the output of the task at runtime and feed it to the to_message function, but I can't figure out how to do that.

It compiles. But it requires some additional syntax.

Oh, that's right! That didn't occur to me. But what does this actually improve? The generic parameter T is still there, so does anything really change? Functions that want to return potentially different tasks still won't be able to, right? UiTask<CreateFoo, Message> is different from UiTask<GetBars, Message>.

I know this is a simplfied example, but it feels like the CoreTask trait is unnecessary, there's not much you can do generically given the example definition of CoreTask (you'll need the information of the concrete type to be able to use the associated type Dependencies), you are dispatching over concrete types anyway.

mod core_tasks {
    pub struct CreateFoo { /* ... */ }
    impl CreateFoo {
        async fn execute_with(self, dependencies: FooDependencies) -> FooOutput {
            todo!()
        }
    }
    
    pub struct GetBars { /* ... */ }
    impl GetBars {
        async fn execute_with(self, dependencies: BarDependencies) -> BarOutput {
            todo!()
        }
    }
}

pub enum UiTask<Message> {
    CreateFoo {
        core_task: core_tasks::CreateFoo,
        to_message: Box<dyn Fn(FooOutput) -> Message>,
    },
    GetBars {
        core_task: core_tasks::GetBars,
        to_message: Box<dyn Fn(BarOutput) -> Message>,
    ),
    // ...
}

match task {
      UiTask::CreateFoo { core_task, to_message } => /* ... */,
      UiTask::GetBars { core_task, to_message } => /* ... */,
}

if you could refine the example a little bit to show how the trait is supposed to be used, it'll be more likely for us to come up a better idea.

1 Like

I created the CoreTask trait (though it's simply called Task and lives in the core crate; I renamed it here for the sake of clarity) to encapsulate commands and queries in one data structure, rather than creating a new application method for each task.

impl CoreApplication {
    pub fn execute<'app, T: CoreTask>(
        &'app mut self,
        task: T,
    ) -> impl Future<Output = <T as CoreTask>::Output>
    where
        <T as CoreTask>::Dependencies: From<&'app mut Self>,
    {
        // Dependencies include shared state, like a DB connection, and stuff like configuration—whatever the task needs.
        let dependencies = self.into();
        task.execute_with(dependencies)
    }
}

In practice, it's really not much different. I suppose it's a matter of preference.

In any case, the problem lies inside the boundaries of the UI crate, not the core crate. If I were, for example, to instead use this in an API server, I wouldn't have this problem. A handler function would deserialize the command (which stores input data) from the HTTP request, feed it to CoreApplication::execute, serialize the output as an HTTP response, and return it.

But I'm using it inside an Iced UI, which is based on the Elm architecture. A common way to scale Elm-style applications is nesting the model-update-view triad, as shown here. In Iced specifically, you're supposed to return an iced::Task from each update function, and to create it, you give it a future. When this future is, say, an HTTP request, you simply build the request using a crate like reqwest, but in my case, I would have to thread my core application through each update function or store it as some global state.

I was trying to avoid this because I wanted to separate UI logic from application-level logic. Therefore, I thought to return core-level tasks (e.g. struct CreatePost { title: String, contents: String }) and leave it to the top-level update function to figure out how to execute it.

Yeah, it's unfortunately not elegant as I thought it might be.

Hearing more about your actual use case, I would separate concerns with an actor that takes responsibility for doing HTTP or database requests. The UI can just use a channel to talk to the actor(s). I don't really use iced (I've tinkered a bit, it was a long time ago) but going through the examples, it's almost exactly the same architecture used in download_progress.

By coupling dependencies to the task, you are doing the exact opposite of separating concerns.