Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Enum Map Derive Macro #279

Merged
merged 24 commits into from
Jan 20, 2024
Merged

Conversation

PokeJofeJr4th
Copy link
Contributor

This started as Issue #272

#[derive(EnumMap)]
enum MyEnum {
  VariantName,*
}

This macro creates a new type MyEnumMap<T> with a field of type T for each variant of MyEnum. It also adds the following:

  • Automatic derive for Debug, Clone, Default, PartialEq, Eq, Hash
  • Implement Index<T> and IndexMut<T>
  • fn new(variant_name: T,*) -> MyEnumMap<T> constructor to specify each struct field
  • fn from_closure(func: Fn(MyEnum)->T) -> MyEnumMap<T> constructor to initialize each struct field using a function
  • fn transform(&self: MyEnumMap<T>, func: Fn(MyEnum,&T) -> U) -> MyEnumMap<U> method to create a new map by applying a function to each field
  • if T implements Clone, generate fn filled(value: T) -> MyEnumMap<T> constructor to fill each struct field with the given vaalue
  • fn all(self: MyEnumMap<Option<T>>) -> Option<MyEnumMap<T>> checks if all fields are Some
  • fn all_ok(self: MyEnumMap<Result<T, E>>) -> Option<MyEnumMap<T>> checks if all fields are Ok
  • variants marked with #[strum(disabled)] are not included as struct fields and panic when passed as an index.

Questions:

  • Should EnumMap be renamed to clarify that it doesn't have many features of conventional maps?
  • Are the all and all_ok functions helpful? Are they within the scope of Strum?
  • I'm currently disallowing extra data on variants. Should I just ignore it and use the discriminant? Should I treat the underlying data as a key to a more conventional map?
  • Are there any other traits/functions I'm forgetting about that should be included?

@PokeJofeJr4th
Copy link
Contributor Author

the build is only failing because the version of rust they're using is out of date

Copy link
Contributor

@itsjunetime itsjunetime left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like it overall. There are a few things that I think could be changed for the better, but I'm happy with it as is and would love to see it merged.

strum_macros/src/macros/enum_map.rs Outdated Show resolved Hide resolved
let mut disabled_matches = Vec::new();

for variant in variants {
// skip disabled variants
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Having a variant that could be disabled and cause a panic if indexed with makes me nervous, but it seems to be in line with what this crate normally does. I feel like it would make more sense, though, to ignore the disabled attribute for this specific macro (among others, like AsRefStr) since generating code for the ignored attributes can't cause any harm.

If they already weren't going to index into this map with that variant (e.g. they were using EnumIter with a disabled variant to iterate through all the variants and modify this EnumMap with each variant), then there's no harm in not panicking if they try to index with what would be a 'disabled' variant. It could cause confusing behavior if they accidentally do index into this with the disabled variant (since it would basically do nothing instead of panicking), but I feel like that's much better behavior than panicking. Perhaps this comment should be made into an issue instead to discuss there.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think that unexpected behavior would be more detrimental to a project than a panic, since the panic is obvious what the problem is

continue;
}

// Error on fields with data
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree with this decision - calling this a map implies that every possible variant has its own value and if you have a variant that has data (e.g. Name(&'static str)) it could potentially be confusing if indexing with that variant but different data (e.g. map[Name("John")] vs map[Name("Jane")]) returns/modifies the same value.

However, this doesn't exactly seem to go in line with what this crate normally does - e.g. with EnumIter, it just generates default::Default() for each field if a variant has data (instead of refusing to expand the macro). If we wanted to keep behavior similar to the other derive macros here, we might want to still expand the macro but just ignore all data on each variant and only match on variant type. I can't think of a specific use-case where allowing data would be necessary, so I'm still in favor of keeping this as-is, but I'm not quite certain what other contributors would be in favor of.

strum_macros/src/macros/enum_map.rs Outdated Show resolved Hide resolved
strum_macros/src/macros/enum_map.rs Outdated Show resolved Hide resolved
strum_macros/src/macros/enum_map.rs Outdated Show resolved Hide resolved
@itsjunetime
Copy link
Contributor

In response to some of your other questions which I've been thinking about:

  1. I'm not quite certain what a better name would be. I guess if there was some way to specify that it's a map that has Some value for every possible key, that could be more clear, but I don't know what that would be.

  2. I feel like the all_ok function is definitely useful, but would be better if it returned a Result<T, E> where it returns Err with the first error encountered while looking through the variants (plus, this would also make it really simple to implement - just EnumMap { #(variant: variant?,)* } in the body). I was previously going to say that all wouldn't be super useful, but I was thinking about my use cases and realized it could actually be really useful. E.g. I would like to store data in an EnumMap to disk, and the library I'm using for that can store a HashMap right out of the box. So I plan to convert the EnumMap to a HashMap when storing like so:

    let mut map = HashMap::new();
    for var in Enum::iter() {
        map[var] = enum_map[var]
    }

    But to convert back after reading the HashMap from the disk, The only safe and clean way I can think of (besides manually getting the value for each variant) would be:

    let Some(enum_map) = EnumMap::from_closure(|v| hash_map.get(v)).all() else {
        println!("oops!");
    };

    So actually, I feel like all could definitely be useful if you need to convert from other similar storage mediums.

  3. I put my thoughts about non-unit variants in another comment

  4. I think impling Clone where T: Clone could be nice. I guess if you wanted to work around it, you could just do EnumMap::from_closure(|v| other_map[v].clone()), but just doing other_map.clone() would be cleaner and clearer a bit.

@PokeJofeJr4th
Copy link
Contributor Author

  1. ChatGPT's best suggestion was MyEnumTable, which I kinda like
  2. I did change the all_ok to what you suggested. However, for your exact case, would it be better to implement a way to directly store the EnumMap?
  3. yeah that's still a hard one
  4. That's already a thing with the #[derive(...)] thingy; I've added a test to clarify that

@itsjunetime
Copy link
Contributor

  1. Yeah, I think calling it a table could be good; if people would prefer that, then I have no objections
  2. It would definitely be better for me to directly store the EnumMap, but with the library that I'm using, that requires deriveing a bespoke macro, which I don't think I can do right now for the generated EnumMap. However, with that in mind, perhaps we could add an attribute like #[enum_map(derive = "MyTrait, MyOtherTrait")] so that you can add other traits to the generated struct's derive attribute? I'm not attached to that syntax, just the general idea of adding extra traits to derive.

And thanks for pointing out that it already derives Clone; I must've missed that.

@PokeJofeJr4th
Copy link
Contributor Author

found a significant issue
PokeJofeJr4th#8

@Peternator7
Copy link
Owner

Could you clarify what the significant issue was? It's good to consider that someone could use a reserved word; can we fix that by prepending an underscore to their identifier?

@PokeJofeJr4th
Copy link
Contributor Author

Sorry, I see now that you have a perfectly fine fix (prepending _). I previously thought we'd have to figure out if it was a reserved keyword and use r# to escape it; but since it's not publicly facing the underscore solution is perfectly valid

Copy link
Owner

@Peternator7 Peternator7 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the work on this PR. I've been busy this summer and haven't had a lot of time to review changes, but I think it's a reasonable feature to include.

strum_macros/src/macros/enum_table.rs Outdated Show resolved Hide resolved
strum_macros/src/macros/enum_table.rs Outdated Show resolved Hide resolved

// Error on empty enums
if pascal_idents.is_empty() {
return Err(syn::Error::new(
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe I missed something. Is this strictly necessary?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's not strictly necessary, but I think it's a good idea to include. Otherwise an empty enum produces this error, which tells the developer a lot less about what they're doing wrong.

error[E0392]: parameter `T` is never used
  --> strum_tests\tests\enum_table.rs:22:10
   |
22 | #[derive(EnumTable)]
   |          ^^^^^^^^^ unused parameter
   |
   = help: consider removing `T`, referring to it in a field, or using a marker such as `PhantomData`
   = help: if you intended `T` to be a const parameter, use `const T: usize` instead
   = note: this error originates in the derive macro `EnumTable` (in Nightly builds, run with -Z macro-backtrace for more info)

For more information about this error, try `rustc --explain E0392`.
error: could not compile `strum_tests` (test "enum_table") due to previous error

The only other valid option I see is using phantomdata, and I don't think it makes sense to allow someone to derive a data structure that can't hold any data.

}
}

impl<T> #table_name<T> {
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we add a get and get_mut methods to compliment Index and match the semantics of arrays, Vecs and HashMaps?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't believe we need these. The main reason to have both Index and get for existing types is that a key doesn't always map to a value. For EnumTable, those methods wouldn't be any different from just using Index and IndexMut. Again, I don't care much on this, just want to make sure we're on the same page.

strum_macros/src/macros/enum_table.rs Outdated Show resolved Hide resolved
}
}
})
}
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What's the plan for Iterator?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it makes sense to just use MyEnum::iter().map(|variant| my_enum_map[variant]). I don't know how to implement it otherwise without basically rewriting all of EnumIter.

/// assert_eq!(complex_map, ColorTable::new(0, 3, 0, 3));
///
/// ```
#[proc_macro_derive(EnumTable, attributes(strum))]
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't feel too strongly, but rust tends to use the "Map" terminology rather than "Table." Thoughts about calling this EnumMap instead?

*I thoughts that's what the feature would be called based on the PR title.

Copy link
Contributor Author

@PokeJofeJr4th PokeJofeJr4th Aug 26, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Edit for clarity: It was originally called EnumMap.
We had a conversation about this a while back and the conclusion was that Maps tend to be dynamically sized based on how full they are, whereas this type always has a value for each key. We thought these differences were enough that calling the structure a Map would cause people to make incorrect assumptions about how it works. That said, I don't feel too strongly about it either.

@itsjunetime
Copy link
Contributor

I feel like it would be really useful to add custom derive commands for each struct this is used on, e.g.

#[derive(EnumTable)]
#[strum(table_derives = "Serialize, Deserialize")]
enum Color {
...
}

this way, ColorTable would have #[derive(Serialize, Deserialize)] added on its declaration since you can't do that otherwise (at least, as far as I know). Would anyone be opposed to this?

@PokeJofeJr4th
Copy link
Contributor Author

I like that but will the namespaces work on it? Would the user having macros like Serialize and Deserialize in the same scope as #[derive(EnumTable)] be enough?

@Peternator7 Peternator7 merged commit df478ed into Peternator7:master Jan 20, 2024
1 check passed
@Peternator7
Copy link
Owner

Published strum version 0.26.0 that includes this feature. I've labeled it experimental because I'm not totally sure the api surface won't change, but think it's an interesting start. Thanks for the work on this and sorry for the delay merging it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants