2
\$\begingroup\$

Application: replacing enum type / description tags in modular software, allowing adding new enum values without updating one definition.

Say we have a video processing pipeline not unlike gstreamer. The main program manages the pipeline where selected modules can read, create or modify image frames. Each frame has several describing attributes, for example a 'type'. The type would traditionally be in a header that's shared by the main program and modules, where all possible values are listed in one enumeration. A module would have a switch or several if statements to act upon frames of the correct type. This is not very easy to maintain as all modules need to be known to the main program.

Proposed solution: Hash a type description text and use that as the numerical value to maintain low overhead if & switch. Like so:

#include <iostream>

/**
 * @brief Convert a string literal to a compile-time 32-bit FNV1A hash
 * 
 * @param str       String literal
 * @param length    String literal length
 * @return uint32_t Hash
 */
constexpr uint32_t operator""_e(const char *const str, const size_t length) {
    uint32_t result { 0x811c9dc5 };

    for (int i = 0; i < length; ++i) {
        result ^= str[i];
        result *= 16777619u;
    }

    return result;
}

int main() {
    constexpr auto num1 = "bla1"_e;
    constexpr auto num2 = "bla2"_e;
    constexpr auto num3 = "bla3"_e;

    std::cout << "0x" << std::hex << num2 << "\n";

    uint32_t i = num2;

    switch (i) {
        case "bla1"_e: std::cout << "1\n"; break;
        case "bla2"_e: std::cout << "2\n"; break;
        case "bla3"_e: std::cout << "3\n"; break;
        default: break;
    }

    static_assert("bla2"_e == 0xb3caa624, "Error, incorrect hash");
}

The idea is that each "..."_e user defined literal can be (re)defined anywhere where needed as long as the use logic ignores unknown values.

Besides the collision risk is there anything else a risk or problem with this approach?

Note: I tested how collision-prone this is. At 32-bit per hash FNV1A starts seeing a first collision around the 200'000 words mark which exponentially increases to 23 at 450'000 words (all from an English words dictionary). For this application that is an acceptable risk. 64-bit hashes have no collisions at 450'000 words.

\$\endgroup\$

2 Answers 2

3
\$\begingroup\$

Design review

If you’re okay with the risk of collision, then I don’t see any major problems with this design. It should work fine.

However, it strikes me as a little over-engineered. It’s the kind of solution where you’re being so clever, you may actually be outsmarting yourself.

The first thing that bugs me about it is that using hashing like this makes potential collisions extremely difficult to predict, or to spot when they happen. Suppose you know someone has used "foo"_e, and you want to use "bar"_e. Now… will those two strings generate the same hash? Probably not, sure, but… how can you know? Practically, the only way to know is to actually check the hashes. You can’t tell just by looking at the strings.

Where this becomes really problematic is when something starts acting strangely in your program. For example, let’s say your program misinterprets certain frames. Because you have allowed for the possibility of hash collisions, that means you have one more thing to add to the checklist of possible bug sources. And to really make that headache pound, to rule that possible bug out completely, you now have to check the hashes for every string used by every module in the program. And remember, you can’t simply tell at a glance whether two strings’ hashes collide… you have to check every one. Unless you have another way to rule out that possibility, this will be a MASSIVE pain in the ass.

The other thing that bugs me about this hash design has nothing to do with collisions, but rather the simple problem that whenever you want to get any information about what is really going on in your program—for debugging, or logging, or whatever—you are going to have a hell of a time getting useful information. For example, let’s say that you notice that an error occurs whenever handling a frame with type 1,340,073,580. Great! So… what exactly does that mean? What was that nice, pretty descriptive string in the source code that hashed to 1,340,073,580? 🤷🏼

Hashing seems silly for a task like this. What, really, do you gain by transforming the lovely little descriptive string irreversibly to a number? Sure, it means you can use any arbitrary string of any arbitrary length… but… do you really need that kind of flexibility? Does it really need to be possible to write all of “Hamlet” as the description for a frame type? Or the poop emoji?

Consider instead putting some restrictions on the string. For example, let’s say that it can only contain Basic Latin characters, and can be a maximum of 8 characters. With that, you can fit it into 64 bits, which means you can trivially translate it to a 64 bit number. Conceptually, that’s kinda like a hash… except it is bijective: there is a 1-to-1 mapping of string to hash, and the mapping is reversible. You get all the benefits of a hash—like lightning fast compares—with the only downside being a limitation on the strings. And you get the additional benefits of meaningful values and easy debugging.

8 characters not enough? Well, then, you could limit the characters to only upper- and lower-case English letters, and digits, which is a total of 62 unique characters, allowing you to add two more characters to get 64… let’s say space and NUL. So you can fit a–z, A–Z, 0–9, space, and NUL in 6 bits. In a 64-bit number, that gives you 10 characters (plus 4 bits of wiggle room).

(And if you really want to use the poop emoji, you could say that the string must be UTF-8 encoded with a max length of 4 code units. That would make your value 32 bits, and your strings could be 4 letters (“Abcd”), or 2 Greek or Cyrillic letters (“ум”), or a single Chinese, Japanese, or Korean ideograph (“知”)… or… well… “💩”. Use 64 bits, and you can have two poops. 👍)

Allow me to illustrate with some quick and hacky code:

// First, create a bespoke type for your things.
//
// Let’s say, for example, we’re making a type for frame type tags.

class frame_type_tag
{
    std::uint_fast64_t _value;  // "64 bits should be enough for anybody."
                                //
                                // Srsly, tho, 64 bits means 8 UTF-8 code
                                // units, assuming char is UTF-8 encoding.
                                // (And you could make that required, with a
                                // little extra work.)

    // Private constructor, so objects can *only* be created using UDLs.
    constexpr frame_type_tag() noexcept = default;

public:
    // So we can compare:
    constexpr auto operator==(frame_type_tag const&) const noexcept -> bool = default;

    // So we can nicely print:
    template <typename Char, typename Traits>
    friend auto operator<<(std::basic_ostream<Char, Traits>& o, frame_type_tag ft) -> std::basic_ostream<Char, Traits>&
    {
        auto buffer = std::array<char, 9>{};

        for (auto i = 0; i != 8; ++i)
            buffer[i] = ft._value >> ((64 - 8) - (i * 8));

        return o << buffer.data();
    }

    friend consteval auto operator""_frame_type(char const*, std::size_t) -> frame_type_tag;
};

// Ignore the actual algorithm; I just slapped it together. It should
// illustrate the point, though.
consteval auto operator""_frame_type(char const* s, std::size_t n) -> frame_type_tag
{
    // Here you can verify that the string is 8 or less characters, that it's
    // all alphanumeric characters, or whatever else you want to check.

    constexpr auto max_n = std::size_t(8);
    
    auto ft = frame_type_tag{};
    ft._value = 0;

    auto i = std::size_t{};
    for (; i != std::min(n, max_n); ++i)
        ft._value = (ft._value << 8) | s[i];

    for (; i != max_n; ++i)
        ft._value <<= 8;

    return ft;
}

auto main() -> int
{
    constexpr auto ft1 = "bla1"_frame_type;
    constexpr auto ft2 = "bla2"_frame_type;

    std::cout.setf(std::ios_base::boolalpha);
    
    std::cout << "frame type 1 is " << ft1 << "\n";
    std::cout << "frame type 2 is " << ft2 << "\n";

    std::cout << "ft1 == ft1: " << (ft1 == ft1) << "\n";
    std::cout << "ft1 == ft2: " << (ft1 == ft2) << "\n";
}

I am glossing over one really important complication, and that is that if the character encoding is different on two machines, the hash will be different. That is true both for my “pseudo-hashes”, and your actual hashes. If you want consistent values for strings across platforms, you will need to make sure the character encoding is consistent. That’s not hard to do… but it will take some doing.

If you don’t care about having consistent hashes across platforms, then you don’t need to worry about it.

The bottom line is this: Hashing makes sense for arbitrary strings… but do you really need arbitrary strings. Limit the length and/or the allowed characters, and you can encode the strings directly by transforming them into a number, which is a bijective function, with all the benefits that brings.

On the other hand, if you really do want to allow completely arbitrary strings, then hashing is about all you can do. In that case, you’ll have to live with the problems.

Code review

constexpr uint32_t operator""_e(const char *const str, const size_t length) {

First, it’s std::uint32_t. And you need to include <cstdint>.

However… what most people don’t realize is that std::uint32_t is not portable. See for yourself: note how it says “optional” next to the std::uintXX_t types?

For portability, what you probably want to use is std::uint_fast32_t. But that creates a new problem, because std::uint_fast32_t is not necessarily going to be exactly 32 bits. If you want only 32 of hash value, then you need to clip it to 32 bits. The easiest way is probably to change the return line to return static_cast<std::uint_fast32_t>(result & 0xffff'ffffu);

It should also be std::size_t. And for that, you need to include something that defines std::size_t. You’ve got options, but the most lightweight choice is probably <cstddef>.

Now, as a matter of style, const char *const is weird. I’m not referring to the weirdness you get because of west-const style (though, I’m not a fan). I’m referring to the fact that in C++, we prefer to put the type modifier with the type.

In other words:

  • const char *const is C style
  • const char* const is C++ style
  • char const* const is also C++ style, east-const 4 lyfe

A more important matter of style is that you should be using a custom type, not std::uint32_t (or std::uint_fast32_t). Using a naked integer type like std::uint32_t means your converted hashes can be mixed up with… well, pretty much anything. You will be able to write absolute gibberish like "bla1"_e + "bla2"_e or std::sqrt("bla3"_e).

What you should do is create a custom type for these things. It can be a simple wrapper around std::uint32_t, but with a more restricted interface. You won’t need the arithmetic stuff, for example. Pretty much all you should need, really, is comparisons. In the example I made in the design review, it just has comparisons, and a print function. You probably don’t need much more than that.

Finally, you don’t say which version of C++ you’re targeting, but if it’s C++20 or better, you might want to use consteval rather than constexpr. The only difference is that constexpr means “could run either at compile time or at run time”… while consteval means “will run only at compile time”, which makes more sense for this.

(Also, if you really do want to allow any arbitrary string, then there’s no way it can fail, so you could as well declare this noexcept.)

Now, you have a number of very subtle bugs in your code because of using the wrong types, and the wrong literals. Let’s start at the top:

uint32_t result { 0x811c9dc5 };

I already mentioned that uint32_t should be std::uint32_t, and in any case it isn’t portable. But putting all that aside… 0x811c9dc5 is a (probably) signed int (or larger) literal. This is problematic for two reasons:

  1. It’s (probably) signed. You want an unsigned literal.
  2. It’s (probably) an int, which may be larger than 32 bits, making this a narrowing conversion… which is a problem, because you’re using braces, which prevent narrowing conversions (which is a good thing).

To make sure this works portably, you should enforce the type you want by using parentheses, not braces, or by using static_cast:

auto result = std::uint32_t(0x811c9dc5u);   // note: parentheses, not braces
// or:
auto result = static_cast<std::uint32_t>(0x811c9dc5u);

But of course, you should use a portable type like std::uint_fast32_t instead of std::uint32_t.

Next up is the loop. Naked for loops are a code smell, mostly because they are shockingly hard to get right and… surprise, surprise… yours isn’t right.

Are you compiling with warnings turned on? If you were, you should have gotten warnings about mixed signed and unsigned comparisons, and maybe also narrowing. You should always compile with all warnings turned on.

int is signed. std::size_t is unsigned. Don’t mix signed and unsigned types. That way lies madness.

More dangerously, though… you don’t know that int is the same size as (or larger than) std::size_t. If someone passes a large enough string in, you could increment right past the max value of int… and for a signed type, that means UB.

What you should be using here is not int, but std::size_t:

for (auto i = std::size_t{}; i != length; ++i)

Note that I also changed the < to !=. This is important for the case where length happens to be the max value of std::size_t. This is the correct way to write this loop.

But all these subtle complexities are why the smart thing to do is not write a loop at all, and instead use an algorithm. For example:

consteval auto operator""_e(char const* s, std::size_t n) noexcept -> std::uint_fast32_t
{
    auto const init = std::uint_fast32_t(0x811c9dc5u);

    auto const hasher = [](std::uint_fast32_t hash, char c) { return std::uint_fast32_t((hash ^ c) * 16777619u); };

    return std::accumulate(s, s + n, init, hasher) & 0xffff'ffffu;
}

That’s not only safer than the naked loop, it’s potentially faster, for a number of reasons. (For example, in your loop, you ask that a pointer calculation and dereference be done for every byte with str[i], whereas the algorithm could load a bunch of bytes at a time, and feed them to the hasher directly. In reality, modern compilers are so smart they will probably optimize the differences away. However, the point stands: the algorithm is simpler, safer, and, at least theoretically, more efficient.)

\$\endgroup\$
1
  • \$\begingroup\$ In practice uint32_t is portable. It's very exceptional these days to program on a CPU that doesn't support 32 bit integers. I would rather keep using uint32_t and have the code behave deterministically (and fail to compile if you are on a platform where that type doesn't exist), than using uint_fast32_t and having to jump through hoops to make that work correctly if that type is larger than 32 bits. \$\endgroup\$
    – G. Sliepen
    Commented Jul 23, 2022 at 18:34
3
\$\begingroup\$

Indi has already reviewed your code. I'll just present an alternative approach to your problem:

Use a std::unordered_map instead of a switch

A module would have a switch or several if statements to act upon frames of the correct type.

Consider using a std::unordered_map to map between strings and the action to perform:

int main() {
    std::unordered_map<std::string, std::function<void()>> actions = {
        {"bla1", []{ std::cout << "1\n"; }},
        {"bla2", []{ std::cout << "2\n"; }},
        {"bla3", []{ std::cout << "3\n"; }},
    };

    std::string type = "bla2";

    // The following code replaces your switch statement
    auto it = actions.find(type);

    if (it != actions.end()) {
        it->second();
    } else {
        // This replaces the default case
        std::cerr << "Unknown type!\n";
    }
}

The unordered map will already hash the strings, but it will also handle collisions for you.

This is not very easy to maintain as all modules need to be known to the main program.

And the map also solves that problem. Notice how the code that actually calls the action to perform doesn't have to know about all the possible types, unlike your switch-statement. This means you can make the map be globally accessible by all modules, who can then add whatever actions they want to the map, without having to make any changes to the main program.

\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.