Project structure help regarding traits and function pointers

I am trying to scaffold a project which offers generic infrastructure and common interfaces for the implementation of many different abstract strategy games. While I am not necessarily porting this from C (since I am doing many things differently from the original project), I am struggling to find my way around in sufficiently generic ways without doing disallowed things.

I have only been writing Rust for a couple months, so I was hoping someone more experienced could help guide me in the early design stages of this project. Here is the problem:

Context

The project includes five noteworthy module sets:

  • Solving algorithms, which operate on games
  • Games, which implement traits representing a few common game archetypes
  • Database writers, which are used by solvers to persist game solutions
  • Analyzers, which do data analysis on solution databases (this is the most independent module)
  • Interfaces, including a CLI, TUI, GUI, and perhaps some networked APIs

...and the project core, which is where everything is called from. As such, I have structured the project as follows based on how interdependent the modules are:

  • Library 1: Solvers, DB writers, Analyzers, and game archetype traits
  • Library 2: Games
  • Library 3: Interfaces
  • Crate: Execution (project core)

Problem

My ambition with this project is to allow for choosing the dynamic-ish execution of different (game, solver) configurations during a single interface session. For example, a user of the CLI might command <name> solve -t "some game" -s "some solver".

The thing to know here is that not all games can be solved by all solvers, which is why I have set up traits and blanket implementations as follows:

/* EXAMPLE SOLVER INFRASTRUCTURE */

trait SolverTypeBSolvable { ... }

impl<G> SolverTypeBSolvable for G
where
    G: GameTypeA,
    G: GameTypeB,
    G: GameTypeC,
{
    fn solve_type_B(&self) { ... } 
}

/* ANOTHER SOLVER */

trait SolverTypeASolvable { ... }

impl<G> SolverTypeASolvable for G
where
    G: GameTypeA,
    G: GameTypeB,
{
    fn solve_type_A(&self) { ... } 
}

Where game archetype traits, which include only behavior possible for specific types of games, are defined in the following way:

trait GameTypeX where Self: Game { ... }

I work on a team, so this results in it being very easy to implement a game: "Just write the implementation for the relevant GameType trait (which is known through research), and you will not only get the most efficient solver possible, but also all the solvers that can solve your game."

This is where the problem lies. I need a way to allow for the choice of not only different games, but once a game is chosen, I need to let a user choose among the solvers which are available for that game (which reduces to getting a collection of the right solver function pointers).

We are more than happy to maintain a list of available games and a map of "what is solved by what", but I don't know how to turn a string into a function signature in a legal and automatic way, without needing to add branch logic for every single possible solver that we make (I want to stray away from doing something like if input=="solverA" { game.solve_type_A(); } else if { ... }).

I would appreciate any advice, expert or not!

It sounds like this is the problem that you are actually trying to solve. I.e., the traits and function pointers question is the XY problem.

Your code implies that SolverTypeBSolvable, for instance, can only solve games that implement GameTypeA and GameTypeB and GameTypeC, which is not precisely what you remarked; "just implement the relevant GameType trait". They would have to implement all relevant GameType traits. The where clause can be rewritten to use G: GameTypeA + GameTypeB + GameTypeC to make the requirements clearer. I can't tell if this was intentional or not.

You are probably not going to get too far with this, if that's a constraint you can't break. You can only type-erase so much. Take for instance a hypothetical HashMap<String, _> to store all of your solvers and look them up by their human-readable names. What type do you name for _? It can't be Box<dyn SolverTypeBSolvable> because that rules out all other solver types. On the other extreme, an even more generic trait like Any loses all information about how to use the solver trait.

In the case of Any, you end up downcasting which requires knowledge of the type you want access to, or exhaustively testing each potential type. So, you're right back where you started with an if-then-else chain.

I think ideally you would want specialization, but it's still very far off: Tracking issue for specialization (RFC 1210) · Issue #31844 · rust-lang/rust (github.com) I've tried multiple variations of designs, and they all run into the same basic problem that motivates specialization.

The kind of program you want can absolutely be written, but not without giving up one of the two constraints you've provided: Use if-then-else or match to do the mapping, or make it more work to implement a new game than "just implement this one trait"... Or possibly you may have to give up both.

At least, that's my take based on how you've defined the problem and my interpretation of it.

2 Likes

Thank you so much. I was indeed confused about implementations and constraints; I incorrectly thought that everything comma separated after a where clause was basically ORed, while everything separated inline with a '+' was ANDed -- I now see this is not the case, and that I should write individual impl items for each trait to have an OR effect.

Also, it's good to know that there isn't an obvious solution I was missing. Having read the specialization proposal, I look forward to this being part of the language internals -- it would be pretty damn great. Just curious, how often are RFCs like these actually brought into the language? Is it a month, year, or years type of thing?

Big features can take a long time because they tend to touch every aspect of the language and compiler. For at least one data point, generic associated types (GATs) stabilized in Rust 1.65.0 (released in November 2022) and the original RFC was written in April 2016.

1 Like

This topic was automatically closed 90 days after the last reply. We invite you to open a new topic if you have further questions or comments.