HashMap<_, Box<dyn Fn...>> - why does this feel wrong?

Other than "you're writing python in rust" - which I absolutely am as far as the inspiration for this pattern is concerned - why does this feel wrong?

I have a situation where I need to call some, or all, of a series of calculations based upon the contents of an "optional iterator of requests".

I initially got frustrated and concerned about duplicating all those calcs in the Some(requests) & None (= do all calcs) arms, so created named closures for each.

Then I got frustrated and concerned about a having both a long if ... if else ... else chain and a listing of calls to all closures.

Now I have:

let mut available_methods: HashMap<Ident, Box<dyn Fn() -> TokenStream2>> = HashMap::new();
available_methods.insert(
    ...
);
...
match attribute.meta {
    syn::Meta::Path(_) => {
        for method in available_methods.values() {
            methods.extend(method())
        }
    }
    syn::Meta::List(_) => {
        let wanted: Punctuated<Ident, Token![,]> =
            attribute.parse_args_with(Punctuated::parse_terminated)?;

        for method in wanted {
            match available_methods.get(&method) {
                Some(method) => methods.extend(method()),
                None => todo!("error for unknown names"),
            };
        }
    }
    syn::Meta::NameValue(_) => todo!("can't process name value"),
};
...

The concerns I can otherwise think of all fall into "if you find yourself here ... you've done something a bit nasty":

  • effectively a stringly-typed API (yep - that's forced onto me, not chosen)
  • an optional parameter for requested calcs (yep - that's partially forced onto me, and partially chosen as better than an arbitrary value "all" in a stringly-typed API)

But still ... this feels like a bad smell that I can't quite put my finger on. Is it obvious to anyone else?

It's not “other than”, it's precisely the reason. Python is the language that spends 95%-99% of CPU power on making it easier for the programmer to write code and only 1%-5% on doing the useful work.

It's not damnation of Python, it's just a design decision. It may be good design decision or bad one depending on your goals.

When you are using Box<dyn Fn…> you are doing the same thing: Fn is usually pretty cheap, Box is heavier and Box<dyn Fn…> and even much heavier (runtime on many languages like Java do that less crippling because it may inline a function cal via indirection… Rust doesn't play these games).

Whether it's good or wrong depends entirely on what you are trying to do. If these Box<dyn Fn…> are used to react to the user — then I would consider is fine, for human is so slow, compared even to modest CPU, if you plan to use these to execute billions of calculations and each Fn includes one or two lines then I would try to do something else, definitely.

It's not good and not bad pattern, it depends entirely on what you are trying to do with it.

You could use fn() -> TokenStream2 perhaps, or if those Fns capture environment, add an argument fn(...) -> TokenStream2.

In general, I would say there's nothing wrong with using Box<dyn Fn…>. Regarding performance, it seems even less relevant here when it's just about things happening in a proc macro (AFAICT), though I also would like to relativize previous calls on Box<dyn Fn…⁠> being "heavy" because in absolute terms it's not really all that heavy at all.

The design feeling less type-safe may be a concern, though it's the right choice if you wouldn't actually know how to do it more type safely easily (or without other downsides / uglyness), since IMHO it's also a reality of Rust that we like being pragmatic and getting real-world things done :innocent:


I can't quite spot where in the code you're handling the optional parameters that you mentioned, or maybe you weren't sharing that. Seeing there's a parameter-less Fn in the map, maybe those could be passed in some better way?


I also see iteration over the whole hashmap. Which is fine but of course a bit of a random order. You could consider a BTreeMap or indexmap for more deterministic ordering - at least in case there are any side effects that might make the order of iteration matter (even if just in subtle ways).

maybe for using dynamic dispatching in a statically (and strongly) typed language?

to be honest, I don't think it's "wrong" to use boxed closures like this. I prefer a (statically and) well-typed solution in general, but I'm not bothered if I use dynamic dispatching, whether forced to or by choice.

that said, if you really really want a more "rusty" style with stronger types, I can see several ways to improve the current code.

you can create a type to represent the concept of "optional iterator of requests", e.g. we can call it WantedMethodSet, in order to alleviate the duplicated Some(request) vs None condition checks, something like:

/// is mathematically a set, can also implement using
// a `HashSet`, or `BTreeSet` if order is to be preserved
// you can use a custom `MethodId` type to replace the `syn::Ident`
// and use various tricks for this "set" type, like bitmaps 
#[derive(Default)]
struct WantedMethodSet {
    names: Vec<Ident>,
}

impl WantedMethodSet {
    // constructor, optionally can also validate method names here
    fn from_iter(names: impl Iterator<Item = Ident>) => Self {
        Self { names: names.collect() }
    }
    // alternatively, validate names after construction,
    // e.g. returns unsupported names for error reports
    fn invalide(&self) -> Vec<Ident> {
        self.names.iter().filter(|name| !is_supported(name)).cloned().collect()
    }
    // encapsulate the `Some(requests)` or `None` condition
    fn is_requested(&self, name: &Ident) -> bool {
        self.is_empty() || self.contains(name)
    }
}

fn is_supported(name: Ident) -> bool {
    [METHOD_FOO, METHOD_BAR, METHOD_BAZ, ...].contains(name)
}

this can then be used in case you use a long if conditions similar to this:

let wanted = match attribute.meta {
    Path(_) => WantedMethodSet::default(),
    List(_) => WantedMethodSet::from_iter(atribute.parse_args_with(...)?.into_iter()),
    NameValue(_) => todo!(),
};
if !wanted.invalid().is_empty() {
    todo!("error report");
}
if wanted.is_requested(&METHOD_FOO) {
    // generate code for method `foo()`
}
if wanted.is_requested(&METHOD_BAR) {
    // generate code for method `bar()`
}
if wanted.is_requested(&METHOD_BAZ) {
    // generate code for method `baz()`
}
//...

this still has some duplications though, e.g. is_supported() needs to know the list of predefined method names.

that's where dynamic dispatching comes into play. but you can still make some improvement to what you have now. for instance, you can wrap the dispatcher map into a new type, and call it, say, MethodRegistry:

// the lazy code generator for a specific method
type MethodTokens = Box<dyn Fn() -> TokenStream2>;

// if you want to be fancy, make this a custom type
// such as a newtype wrapper, or an enum, in which case
// you can even implement `Parse` if you want to be fancy
// so you can parse it directly into `Puncutated<MethodId, Delim>`
type MethodId = Ident;

#[derive(Default)]
struct MethodRegistry(HashMap<MethodId, MethodTokens>);

impl MethodRegistry {
    fn register(&mut self, name: MethodId, tokens: MethodTokens) {
        self.0.insert(name, tokens);
    }
    fn generate_all(&self, accu: &mut TokenStream2) {
        for tokens in self.0.values() {
            accu.extend(tokens());
        }
    }
    fn generate_requested(&self, requested: impl Iterator<Item = &MethdId>, accu: &mut TokenStream2) {
        for name in requested {
            match self.0.get(name) {
                //...
            }
        }
    }
}

then the dispach looks like this:

// alternatively, use a static `LazyLock` if the registry should be reused multiple times
let registry = todo!();
match attribute.meta {
    Path(_) => registry.generate_all(&mut methods),
    List(_) =>
        registry.generate_requested(
            attribue.parse_args_with(Punctuated::...).into_iter(),
            &mut methods,
        ),
    NameValue(_) => todo!(),
}

this is just some ideas for you to start playing with.

I think this:

// parsed as `Meta::Path(_)` => requests are empty, or `None`, generate ALL available methods
#[generate_method]
struct Foo{}

// parsed as `Meta::List(_)` => requests are `Some`, generate only `foo()`, `bar()` `baz()`
#[generate_method(foo, bar, baz)]
struct Bar{}

Yeah, it's a proc macro and right now I'm not at the point of fine-tuning performance. I'd like to minimise compile times as much as possible. But the trade-off is less "easier for the programmer" and more "bug risk through having multiple places to touch for any one change" and "prefer bugs which the complier finds to bugs which need a test to find"

I actually feel more type (and typo) safe here than with if wanted == "str1" else if wanted == "str2" ... else

It's the subtle implication of

match attribute.meta {
    syn::Meta::Path(_) => {
          // no parameters were passed - #[methods]
    }
    syn::Meta::List(_) => {
          // parameters were passed - #[methods(meth1, meth2)]

    }
    syn::Meta::NameValue(_) => todo!("can't process name value"),
};

Probably.

That's taking me to even more dynamic dispatch - registering at runtime. Right now, I don't have a dynamic registration; all methods are predefined in the code.

I have a 3rd-party one of those already: syn::Meta - adding a custom type with a From impl & a series of methods feels like overkill vs simply matching on Meta and still leaves the main concern, just moves it further away in the code.


Thanks, you put my mind at rest that the balance isn't too bad right now in the specific circumstances

if those lazy generators are captureless, or they can share some common runtime states, it might be possible to replace the hashmap with a constant table: Ident can be compared with &str directly, (i.e. PartialEq<str>), and plain function pointers are const friendly:

struct GeneratorContext { ... }
const AVAILABLE_METHODS: &[(&'static str, fn(&GeneratorContext) -> TokenStream2] = &[
    ("foo", generate_method_foo),
    ("bar", generate_method_bar),
    ("baz", generate_method_baz),
];

fn is_supported(requested_name: &Ident) -> bool {
    AVAILABLE_METHODS.iter().find(|(name, _)| requested_name == name).is_some()
}

fn generate_all(ctx: &GeneratorContext, accu: &mut TokenStream2) {
    //...
}
fn generate_requested(ctx: &GeneratorContext, requested: impl Iterator<Item = Ident>, accu: &mut TokenStream2) {
    //...
}

let ctx = todo!();
match attribute.meta {
    Path(_) => generate_all(&ctx, &mut methods),
    List(_) =>
        generate_requested(
            &ctx,
            attribute.parse_args_with(...).into_iter(),
            &mut methods
        ),
    NameValue(_) => todo!(),
}

if this can be adapted for your use case, it can eliminate the boxing and the vtable altogether.

correct, I think it's not bad at all.

that feels more idiomatic - but also more clunky than what I have.

  • I can iterate over the array for the "all" case :check_box_with_check:
  • there is nothing to stop me forgetting to include if else wanted == "bar" { generate_method_bar } in generate_requested :cross_mark:
  • or worse, from copy-pasting if else wanted == "baz" { generate_method_bar } :cross_mark:
  • unless I use let (_, generate) = ...find(|(name, _)| ...) - which swaps the allocation costs of the Hashmap and Box dyn Fn for the (repeated) iteration costs of not having indexing :person_shrugging:

:check_box_with_check:

that's what I intended for this arrangement.

I assume the list would be small enough that an linear search is as fast as, or faster than, a hash table lookup. if your problem size is reasonably large, then a hash table is definitely better. but still, you may reduce a large portion of the allocation overhead if you can use function pointers (also supports captureless closures) instead of boxed closures.

When applied to a captureless closure, or any other zero-sized type, Box::new() does not allocate. Thus, it is nearly free to use compared to function pointers.[1]


  1. It costs one additional indirection when called, because the function pointer is stored in the vtable instead of directly. ↩︎

I think I have a clearer explanation for the "wrong" feeling, let me try.

rust gives us fine control over these decisions and tradeoffs, which also gives us awareness for the low level details and performance implications. when we explicitly spell out Box, we know there's heap allocations (except for ZST); when we explicitly write the keyword dyn, we know there's indirection via vtables; notably, these are the non-"default" way of doing things in rust: we generally prefer stack values and static dispatching.

awareness gives us more insight and confidence, but at the same time, it is also extra cognitive burden. and I think what you had about "why does this feel wrong?" is exactly this sort of burden in mind.

again, this doesn't mean it's really "wrong", awareness just means we desire to understand the tradeoffs, instinctively, and sometimes subconsciously, and that's usually a good indicator that our brains "think in rust".

if the mechanism were implicit (which also means we don't have control over the tradeoffs), we probably would not think about it too much, or at all, because that's the way to do it anyway.

for example, we don't usually think about the heap and indirections when we use closures, or functions, in a dynamically typed languages like python or js, we simply write the keyword lambda, def, function, etc, and go with it.


a bit personal story. I had hardly used std::function back when I wrote C++: I really don't like the hidden "magical" small object optimization that I don't have any control of. the standard mandates this "optmization" only for plain (i.e. non-member) function pointers and std::reference_wrapper, otherwise it's a quality-of-implementation detail. let's not even talk about the madness of member function poiners and the workarounds such as std::bind.

I'd rather prefer an old school C style "fat pointer" (a function pointer plus an erased data pointer) approach when I need to store a collection of heterogeneous callable objects, this way I have total control of heap allocations if I need to (be aware of virtual destructors, C++ doesn't have the concept of borrows and lifetimes, so you rely on documentations for the ownership of the erased pointer). and with some clever type-erasure tricks (remember "the impossibly fast C++ delegate", anyone?), this also works very well for member functions and lambdas, and in a type-safe way too (type-safe in the C++ sense, regarding those static_casts of pointers, there's no guarantee of memory-safety).

It's fine.

The trap is that dyn Fn gives you exactly one method you can call per object. It's quite literally a trait with one method call(). If you ever need a second method, you'll need to switch to your own trait.

Besides that, it's a dynamic observer pattern. There's nothing wrong with that if you need it to be dynamic. It's merely a small efficiency waste if you a hardcoded set of methods.