DEV Community

Cover image for When AI gets C++ bitmask enums almost right
piko::tutorial
piko::tutorial

Posted on • Originally published at pikotutorial.com

When AI gets C++ bitmask enums almost right

Today I want to take a look at a piece of C++ code that Copilot generated for me recently. The code here will not be 1:1 the same because originally it was inserted as a part of the bigger code base - I changed the names and simplified it to focus on the key topic, but the overall idea is exactly the same.

At first glance, the generated code looked perfectly reasonable. In fact, it even compiled. The problem is that it also contained a subtle semantic trap that is very easy to miss if you don't stop and think carefully about what the code is actually meant to do.

The code started with an enum representing permissions where the values are bitmasks:

enum class Permission
{
    kNone = 0,
    kRead = 1 << 0,
    kWrite = 1 << 1,
    kExecute = 1 << 2
};
Enter fullscreen mode Exit fullscreen mode

And then Copilot generated an associated stream operator to print the enum value using std::cout:

std::ostream& operator<<(std::ostream& os, const Permission p)
{
    switch (p) {
        case Permission::kNone:
            return os << "None";
        case Permission::kRead:
            return os << "Read";
        case Permission::kWrite:
            return os << "Write";
        case Permission::kExecute:
            return os << "Execute";
        default:
            return os << "UNKNOWN PERMISSION";
    }
}
Enter fullscreen mode Exit fullscreen mode

So far, everything still looks fine. The next step overloads the bit or operator for the Permission enum:

Permission operator|(const Permission lhs, const Permission rhs)
{
    return static_cast<Permission>(static_cast<int>(lhs) | static_cast<int>(rhs));
}
Enter fullscreen mode Exit fullscreen mode

Nothing immediately suspicious here either, but the things start to look interesting because Copilot generated also a function that, according to some complex logic, returns combinations of permissions in form of:

Permission GetPermissions()
{
    return Permission::A | Permission::B;
}
Enter fullscreen mode Exit fullscreen mode

This is where things start getting tricky. The code is not obviously wrong because representing flags as bitmasks is a completely valid technique, but let's test this with a simple example:

Permission GetPermissions()
{
    return Permission::kNone | Permission::kWrite;
}

int main()
{
    const Permission p = GetPermissions();
    std::cout << "Current permissions: " << p;
}
Enter fullscreen mode Exit fullscreen mode

This works perfectly fine because:

00000000 | 00000010 = 00000010
Enter fullscreen mode Exit fullscreen mode

And 00000010 corresponds exactly to Permission::kWrite, so the output becomes:

Current permissions: Write
Enter fullscreen mode Exit fullscreen mode

However, when the function reaches a path like this:

Permission GetPermissions()
{
    return Permission::kRead | Permission::kWrite;
}
Enter fullscreen mode Exit fullscreen mode

the output suddenly changes to:

Current permissions: UNKNOWN PERMISSION
Enter fullscreen mode Exit fullscreen mode

How is this possible that although all enum values of the Permission enum are covered in switch/case statement, after getting the object p of type Permission and printing it, no valid value of the enum is displayed?

The answer is: the value returned from GetPermissions() is simply not represented by any enum field. Take a look:

Permission::kRead  = 00000001
Permission::kWrite = 00000010
Enter fullscreen mode Exit fullscreen mode

Their bitwise or produces:

00000011
Enter fullscreen mode Exit fullscreen mode

But there is no enum field equal to 00000011 and C++ does not forbid such value from existing inside an enum object.

The key problem

This reveals a deeper conceptual conflict:

  • enums are designed to represent one value from a closed set
  • bitmasks are designed to represent combinations of values

These are fundamentally different concepts and without writing a logic which actually enforces the rules of how these 2 contradicting worlds co-exist, we end up with a subtle bug hidden behind a clean API. Of course, the solution is not to keep extending the enum with values like:

kReadWrite
kWriteExecute
kReadExecute
Enter fullscreen mode Exit fullscreen mode

because this does not scale. With more flags, the number of combinations grows exponentially. Let's look at how to fix the issue properly.


AI is powerful. Snippets are instant.

Stop prompting for the same patterns repeatedly. Get almost 100 free VS Code snippets for C++, Python, CMake and Bazel from piko::snippets GitHub repository.


Don't lie with the function signature

Using enums to represent individual bitmask values is perfectly fine. It improves the usage and the readability because you no longer need to remember whether "read" permission is equal to 1, 2, or 16. But the moment you start combining those values, the result is no longer guaranteed to be a valid Permission enum value, so the API should reflect that reality.

The first step is changing the overloaded | operator - instead of pretending that the result is still a Permission, return an integer mask explicitly:

int operator|(const Permission lhs, const Permission rhs)
{
    return static_cast<int>(lhs) | static_cast<int>(rhs);
}
Enter fullscreen mode Exit fullscreen mode

Now the operator no longer falsely suggests that combining two permissions produces another valid enum field. Similarly, any function returning combined permissions should return an integer mask instead of Permission:

int GetPermissions()
{
    return Permission::kRead | Permission::kWrite;
}

int main()
{
    const int p = GetPermissions();
    std::cout << "Current permissions: " << p;
}
Enter fullscreen mode Exit fullscreen mode

This now produces:

Current permissions: 3
Enter fullscreen mode Exit fullscreen mode

The value is no longer pretending to be a single enum value. It is simply a bitmask containing multiple flags what is clearily communicated by the API now. If you want, you can print it in a nicer form by using a helper function - here is an example of a simple one:

void PrintPermissions(const int p)
{
    if (p & static_cast<int>(Permission::kRead)) {
        std::cout << "Read";
    }

    if (p & static_cast<int>(Permission::kWrite)) {
        std::cout << "Write";
    }

    if (p & static_cast<int>(Permission::kExecute)) {
        std::cout << "Execute";
    }

    std::cout << std::endl;
}
Enter fullscreen mode Exit fullscreen mode

Now the output becomes:

Current permissions: ReadWrite
Enter fullscreen mode Exit fullscreen mode

Top comments (1)

Collapse
 
fromzerotoship profile image
FromZeroToShip

I don't write C++ — I'm a physical therapist who builds hospital tools with AI — but "compiled cleanly, semantically wrong" is the exact trap I live in, just wearing different clothes. Mine is a health check that returns a cheerful 200 while the database behind it is dead. The compiler said yes; reality said no. Same gap you found, one layer up.

What I love here is that the real fix isn't "make the AI smarter" — it's honest types. The enum was lying: it promised "one value from a closed set," then handed back a combination that wasn't in the set. Returning int is the type finally telling the truth about what it is. I did the same thing this week without having your words for it: my check stopped returning a label ("ok") and started returning a receipt — what ran, when, and an explicit field for "no degradation." The type stopped pretending.

The part that scares me is that AI produces the confident-looking version by default. It hands you the enum operator that compiles, not the int one that's honest, because the first reads cleaner. Fluency isn't correctness — the code that reads best is often the one quietly hiding the closed-set assumption. Filing this away. Thanks for writing it.