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
답글 남기기