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

Should enum_name(E value) (and maybe others?) be prevented from working with empty enums? #320

Closed
ts826848 opened this issue Dec 2, 2023 · 9 comments
Labels
enhancement New feature or request question Further information is requested
Milestone

Comments

@ts826848
Copy link
Contributor

ts826848 commented Dec 2, 2023

I just finished helping a colleague track down some strange test failures that appear to stem from enum_name(E value) unintentionally behaving as if E were an empty enum. It took a bit to track down this specific issue because the code that actually used enum_name(E value) wasn't touched in the problematic commit. Some other unrelated shuffling of code appears to have confused the compiler so it started treating the enum in question as a plain forward declaration rather than its full definition, so enum_name(E value) was returning empty strings instead of the correct names.

This got me thinking - should enum_name(E value) work at all if the enum is empty? enum_name<auto V>() has a static_assert to bail if the returned name is empty, so changing enum_name(E value) to bail on empty enums and/or empty names would arguably make the behaviors match and could help catch unintentional changes like this where a previously-defined enum starts being treated as a forward declaration.

I think this question could also be extended to other parts of what magic_enum offers, but enum_name is definitely the place where I've experienced issues the most.

And if enum_name(E value) should work with empty enums, should it return a std::optional<std::string_view> to distinguish between the "no name due to invalid value/empty enum" and "empty name" cases?

@ts826848
Copy link
Contributor Author

ts826848 commented Dec 3, 2023

For full disclosure - my current guess for the root cause was that the refactoring accidentally introduced an ODR violation. The code that was pulled out was missing the include for the enum, so enum_name(E value) was treating it as an empty enum in that TU. This conflicts with the definition in the symptomatic TU, and the linker (?) ends up pulling in the empty-enum definition into the symptomatic TU.

Having enum_name reject empty enums would have helped catch this much earlier, and would also have pointed me to the problematic TU, which would have made debugging this much faster.

@Neargye Neargye added this to the v0.9.6 milestone Dec 4, 2023
@Neargye
Copy link
Owner

Neargye commented Dec 4, 2023

Hi,

  1. I think that adding asserts is the easiest option without breaking backward compatibility (like return std::optional<std::string_view>)

  2. I’m also thinking of adding settings via macros(?) to disable these asserts.

  3. I think it's worth adding this to all APIs.

What do you think about it?

@Neargye Neargye added enhancement New feature or request question Further information is requested labels Dec 4, 2023
@ts826848
Copy link
Contributor Author

ts826848 commented Dec 5, 2023 via email

@thirtythreeforty
Copy link

I am doing something similar on purpose:

    // Forward declare (values are intentionally deferred until after the
    // names TESTs are defined):
    enum MyModuleTapNames: uint32_t;

    // later, in the .cpp file:
    enum MyModuleTapNames {
        Foo,
        Bar,
    };

This allows me to hide the definitions from all translation units except the one that cares about it, and erase type information to generically have a system to grab enum names and counts from each translation unit.

should enum_name(E value) work at all if the enum is empty

We should distinguish here between enum MyModuleTapNames {}; and enum MyModuleTapNames;; the latter type is still incomplete and I think it should be rejected. (Or, provide an API to check if it is complete - which is how I found this issue :) But the former type has been completely defined to have no entries and I think it should be accepted.

If there's some technical restriction that prevents distinguishing the two cases, then let me know.

@ts826848
Copy link
Contributor Author

ts826848 commented Dec 7, 2023

If there's some technical restriction that prevents distinguishing the two cases, then let me know.

At least based on some cursory searches it seems a fully conforming generally-useable is_complete template is not possible to implement. The tricky part is that template instantiations using the same type(s) need to behave the same, otherwise you (can?) get ODR violations with unpredictable results (like the behavior that spawned this issue :P). In addition, the behavior of such a thing may be counterintuitive if you have is_complete<T> followed by a definition of T followed by another is_complete<T>. You may be able to get around this by limiting instantiations to a single TU and/or by using __COUNTER__, but the latter isn't technically conformant and I'm not confident the former is guaranteed to work, especially if present in a header file.

That's part of why I suggested an is_empty flag - to allow magic_enum to distinguish between "this enum is intentionally empty" and "this enum may be empty or forward-declared and I can't tell". Of course, if there is a way to detect a forward-declared enum that solution seems moot.

@Neargye
Copy link
Owner

Neargye commented Dec 8, 2023

@ts826848 @thirtythreeforty Please check this PR for possible solution #323

@thirtythreeforty
Copy link

This partially works for me. It does correctly reject a declaration like

enum class MyEnum;

But, it does not reject a declaration that includes the underlying type:

enum class MyEnum: uint32_t;

@ts826848
Copy link
Contributor Author

ts826848 commented Dec 8, 2023

Please check this PR for possible solution #323

The PR catches the error that I originally ran into, so I think it works for me. I think it should work for most others users as well; the only exception would be if a user intentionally has both empty and non-empty enums but prefers to keep the checks for non-empty enums if possible. I don't know whether that case will come up at all, though, and I think allowing checks to be done on a case-by-case basis should be fairly straightforwards.

I've left a few comments on the PR as well, but they're pretty much nitpicks.

But, it does not reject a declaration that includes the underlying type:

That's strange. The check is just std::is_enum_v<E> & (count_v<E, S> > 0), so it's not immediately obvious to me why the presence/absence of a declared underlying type should matter, especially for a scoped enum. I can't reproduce this behavior using either Clang or GCC as well. My test case, run in a file in the repo root:

#include "include/magic_enum/magic_enum.hpp"

enum class Forward : uint32_t;
auto f(Forward f) { return magic_enum::enum_name(f); }

I get a static_assert whether Forward has a declared type or not.

@thirtythreeforty
Copy link

Ah, sorry. You are correct. I was relying on CLion's static analysis rather than running a build. I see your described behavior on Clang 16 and GCC 13.2. So it is working for me! Thank you!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
enhancement New feature or request question Further information is requested
Projects
None yet
Development

No branches or pull requests

3 participants