Daniel Porteous
Daniel Porteous

Reputation: 6343

How do I inspect function arguments at runtime in Rust?

Say I have a trait that looks like this:

use std::{error::Error, fmt::Debug};
use super::CheckResult;

/// A Checker is a component that is responsible for checking a
/// particular aspect of the node under investigation, be that metrics,
/// system information, API checks, load tests, etc.
#[async_trait::async_trait]
pub trait Checker: Debug + Sync + Send {
    type Input: Debug;

    /// This function is expected to take input, whatever that may be,
    /// and return a vec of check results.
    async fn check(&self, input: &Self::Input) -> anyhow::Result<Vec<CheckResult>>;
}

And say I have two implementations of this trait:

pub struct ApiData {
    some_response: String,
}

pub MetricsData {
    number_of_events: u64,
}

pub struct ApiChecker;

impl Checker for ApiChecker { 
    type Input = ApiData;

    // implement check function
}
 
pub struct MetricsChecker;

impl Checker for MetricsChecker { 
    type Input = MetricsData;

    // implement check function
}  

In my code I have a Vec of these Checkers that looks like this:

pub struct MyServer {
    checkers: Vec<Box<dyn Checker>>,
}

What I want to do is figure out, based on what Checkers are in this Vec, what data I need to fetch. For example, if it just contained an ApiChecker, I would only need to fetch the ApiData. If both ApiChecker and MetricsChecker were there, I'd need both ApiData and MetricsData. You can also imagine a third checker where Input = (ApiData, MetricsData). In that case I'd still just need to fetch ApiData and MetricsData once.

I imagine an approach where the Checker trait has an additional function on it that looks like this:

fn required_data(&self) -> HashSet<DataId>;

This could then return something like [DataId::Api, DataId::Metrics]. I would then run this for all Checkers in my vec and then I'd end up a complete list of data I need to get. I could then do some complicated set of checks like this:

let mut required_data = HashSet::new();
for checker in checkers {
    required_data.union(&mut checker.required_data());
}

let api_data: Option<ApiData> = None;
if required_data.contains(DataId::Api) {
    api_data = Some(get_api_data());
}

And so on for each of the data types.

I'd then pass them into the check calls like this:

api_checker.check(
    api_data.expect("There was some logic error and we didn't get the API data even though a Checker declared that it needed it")
);

The reasons I want to fetch the data outside of the Checkers is:

  1. To avoid fetching the same data multiple times.
  2. To support memoization between unrelated calls where the arguments are the same (this could be done inside some kind of Fetcher trait implementation for example).
  3. To support generic retry logic.

By now you can probably see that I've got two big problems:

  1. The declaration of what data a specific Checker needs is duplicated, once in the function signature and again from the required_data function. This naturally introduces bug potential. Ideally this information would only be declared once.
  2. Similarly, in the calling code, I have to trust that the data that the Checkers said they needed was actually accurate (the expect in the previous snippet). If it's not, and we didn't get data we needed, there will be problems.

I think both of these problems would be solved if the function signature, and specifically the Input associated type, was able to express this "required data" declaration on its own. Unfortunately I'm not sure how to do that. I see there is a nightly feature in any that implements Provider and Demand: https://doc.rust-lang.org/std/any/index.html#provider-and-demand. This sort of sounds like what I want, but I have to use stable Rust, plus I figure I must be missing something and there is an easier way to do this without going rogue with semi dynamic typing.

tl;dr: How can I inspect what types the arguments are for a function (keeping in mind that the input might be more complex than just one thing, such as a struct or tuple) at runtime from outside the trait implementer? Alternatively, is there a better way to design this code that would eliminate the need for this kind of reflection?

Upvotes: 1

Views: 689

Answers (1)

kmdreko
kmdreko

Reputation: 60092

Your problems start way earlier than you mention:

checkers: Vec<Box<dyn Checker>>

This is an incomplete type. The associated type Input means that Checker<Input = ApiData> and Checker<Input = MetricsData> are incompatible. How would you call checkers[0].check(input)? What type would input be? If you want a collection of "checkers" then you'll need a unified API, where the arguments to .check() are all the same.


I would suggest a different route altogether: Instead of providing the input, provide a type that can retrieve the input that they ask for. That way there's no need to coordinate what type the checkers will ask for in a type-safe way, it'll be inherent to the methods the checkers themselves call. And if your primary concern is repeatedly retrieving the same data for different checkers, then all you need to do is implement caching in the provider. Same with retry logic.

Here's my suggestion:

struct DataProvider { /* cached api and metrics */ }

impl DataProvider {
    fn fetch_api_data(&mut self) -> anyhow::Result<ApiData> { todo!() }
    fn fetch_metrics_data(&mut self) -> anyhow::Result<MetricsData> { todo!() }
}

#[async_trait::async_trait]
trait Checker {
    async fn check(&self, data: &mut DataProvider) -> anyhow::Result<Vec<CheckResult>>;
}

struct ApiAndMetricsChecker;

#[async_trait::async_trait]
impl Checker for ApiAndMetricsChecker {
    async fn check(&self, data: &mut DataProvider) -> anyhow::Result<Vec<CheckResult>> {
        let _api_data = data.fetch_api_data()?;
        let _metrics_data = data.fetch_metrics_data()?;
        
        // do something with api and metrics data
        
        todo!()
    }
}

Upvotes: 2

Related Questions