Het complement van true is true, behalve wanneer het false is
Ik heb onlangs gekeken naar P4313R1, een voorstel voor de standaard die een reeks bitmasker-operaties toevoegt voor enums, waarbij een C++26-annotatie wordt gebruikt om deze te activeren. Het kernidee is dat, gebruikmakend van een voorbeeld uit het document:
enum class [[=std::bitmask_type]] Permission {
None = 0,
Read = 1 << 0,
Write = 1 << 1,
Execute = 1 << 2,
};
De [[=std::bitmasktype]]-annotatie zou Permission automatisch voorzien van een toegankelijke set bitwise-operaties, zodat je als gebruiker deze niet zelf hoeft te schrijven. Dit zette me aan het denken over de duistere onderbuik van C++ integer-operaties. Een C++-compiler zal zonder problemen bitwise-operaties berekenen voor elk integraal type als een ingebouwde operatie. Dit strekt zich uit tot types die we traditioneel niet als integers beschouwen, zoals wchart, de UTF-karaktertypes char8t tot char32t, en bool.
Maar er gebeurt iets meer dan op het eerste gezicht lijkt. Wanneer je een operatie probeert uit te voeren zoals a | b, en het type van a en b is een integraal type dat kleiner is dan int, opereert de taal niet direct op de bitpatronen van a en b. Ze ondergaan integral promotion: ze worden gepromoveerd naar int als tussenstap, de bitpatronen van deze twee integers worden gecombineerd, en het resultaat wordt aan jou geretourneerd, nog steeds als een int.
Beschouw het volgende:
// Twee shorts
constexpr short perm_A {1 << 0};
constexpr short perm_B {1 << 1};
// En het resultaattype van een bitwise-operatie op deze is int
static_assert(std::same_as<decltype(perm_A | perm_B), int>);
Voor het grootste deel is dit ongevaarlijk. Als je permA | permB terug cast naar short, worden de onnodige bytes weggesneden (truncated) en houd je een short over met exact de waarde die hij zou hebben gehad als je permA en permB direct als short had gecombineerd. En als je zeker wilt weten dat je je types consistent houdt en de verraderlijke bugs van stille narrowing conversions vermijdt, kun je in de gewoonte komen om het resultaat van je bitwise-operatie met static_cast terug te zetten naar het oorspronkelijke type. Het belangrijke hier is echter dat integral promotion niet optioneel is. In tegenstelling tot de meeste andere delen van C++ heeft de ontwikkelaar geen keuze; je types zullen onvermijdelijk worden gepromoveerd voor deze operaties.
Het tweede deel dat dit specifiek voor bool tot een gevaar maakt, is de boolean-conversie. Dit is een speciaal geval dat niet simpelweg wegsnijdt, maar expliciet nul converteert naar false en niet-nul waarden naar true. Voor deze niet-nul waarden wordt elk bitpatroon dat voorheen was opgeslagen weggegooid en vervangen door true, wat een integer-waarde van 1 heeft.
Als we code nemen zoals:
int x{10};
bool b{static_cast<bool>(x)};
en kijken naar de gegenereerde assembly (in dit geval van ongeoptimaliseerde x86-64 gcc 16.2):
mov DWORD PTR [rbp-4], 10 ; Sla de waarde 10 op
cmp DWORD PTR [rbp-4], 0 ; Vergelijk met 0, zet ZF als x nul is
setne al ; Schrijf 1 naar AL als ZF leeg is
mov BYTE PTR [rbp-5], al ; Sla het resultaat op
De standaard (specifiek [conv.bool]) baseert de conversie naar bool op de enige mogelijke waarden die een bool kan bevatten — true en false — ongeacht welk bitpatroon oorspronkelijk werd gebruikt om ze te creëren.
De puzzelstukjes samenvoegen
Nu we dit hebben behandeld, laten we kijken wat er gebeurt als je probeert ~true te evalueren en het resultaat vervolgens cast naar een bool:
- De waarde
truewordt gepromoveerd naar eenintmet een waarde van 1. - Het complement van 1 als een
intwordt berekend als -2, aangezien two's complement gedrag vereist is vanaf C++20. - De waarde -2 wordt vervolgens teruggecast naar
bool, ondergaat boolean-conversie, en aangezien -2 niet nul is, wordt dittrue.
Daar heb je het: het complement van true is true, of in C++-termen: static_cast<bool>(~true) == true.
Dit brengt ons terug naar enums. Een enum mag elk integraal type gebruiken als onderliggend type, inclusief onze goede vriend bool. Laten we er een definiëren:
enum class [[=std::bitmask_type]] boolean : bool {
FALSE,
TRUE,
};
Laten we ook kijken naar de bitwise-complement-operator zoals beschreven in P4313R1:
template<bitmask-like T> constexpr T operator~ (T lhs) noexcept {
return static_cast<T>(~to_underlying(lhs));
}
De werking van die operator zou nu een bekend patroon moeten zijn: eerst converteren we de enum naar het onderliggende type (in ons geval bool), dan wordt integral promotion toegepast als dat type een lagere rang heeft dan int, dan voeren we de bitwise-operatie uit, en tenslotte casten we terug naar het enum-type. Zoals verwacht:
static_assert(~boolean::TRUE == boolean::TRUE);
Dit heeft alle potentie om een verwarrende corner case in de taal te zijn.
Wanneer het false is
Er is één uitzonderlijk geval dat het probleem verergert. Als we hetzelfde voorbeeld in gcc proberen uit te voeren, krijgen we een ander resultaat:
static_assert(~boolean::TRUE == boolean::FALSE);
Wat is hier aan de hand met gcc? Als we de operatie verplaatsen naar runtime door twee functies te definiëren die afhangen van een runtime-waarde:
enum class boolean : bool {
FALSE,
TRUE,
};
void f(int x, bool& out) {
out = static_cast<bool>(x);
}
void g(int x, boolean& out) {
out = static_cast<boolean>(x);
}
en kijken naar de assembly (weer ongeoptimaliseerde x86-64), dan zien we:
"f(int, bool&)":
push rbp
mov rbp, rsp
mov DWORD PTR [rbp-4], edi
mov QWORD PTR [rbp-16], rsi
cmp DWORD PTR [rbp-4], 0
setne dl ; hetzelfde setne-patroon als eerder bij [conv.bool]
mov rax, QWORD PTR [rbp-16]
mov BYTE PTR [rax], dl
nop
pop rbp
ret
"g(int, boolean&)":
push rbp
mov rbp, rsp
mov DWORD PTR [rbp-4], edi
mov QWORD PTR [rbp-16], rsi
mov eax, DWORD PTR [rbp-4]
and eax, 1 ; bewaar het laagste bit in eax
mov rdx, QWORD PTR [rbp-16]
mov BYTE PTR [rdx], al ; en verplaats al naar de uit-parameter
nop
pop rbp
ret
Wat we zien is dat wanneer het type niet expliciet als bool is benoemd, gcc het resultaat simpelweg wegsnijdt naar het laagste bit in plaats van een boolean-conversie uit te voeren. Elke even waarde zal bij het wegsnijden boolean::FALSE opleveren, en elke oneven waarde boolean::TRUE.
Dit is uniek voor enum-types; gcc gedraagt zich over het algemeen consistent wanneer bool direct wordt behandeld:
constexpr boolean b{boolean::TRUE};
static_assert(static_cast<bool>(~b) == false);
static_assert(static_cast<bool>(~true) == true);
static_assert(std::to_underlying(~b) == false);
static_assert(static_cast<bool>(~std::to_underlying(b)) == true);
In tegenstelling hiermee zullen Clang en MSVC alle vier deze combinaties van complement-en-cast evalueren als true, wat is wat de standaard voorschrijft. Dit lijkt een vrij hardnekkige conformance bug in gcc te zijn.
Unscoped enums, promotie en ongedefinieerd gedrag
Er is echter één plek in de standaard waar de regels voor integral promotion verder gaan en een nieuwe vector introduceren voor ongedefinieerd gedrag (undefined behaviour, UB) in je programma bij het uitvoeren van deze bitmasker-operaties. Beschouw deze code:
enum nums {
zero,
one,
two,
three,
};
// Dit is implementatie-afhankelijk, maar geldt voor gcc en Clang.
static_assert(std::is_same_v<std::underlying_type_t<nums>, unsigned int>);
// nums gebruikt een unsigned int als basis, dus het hele domein van representeerbare waarden is >= 0.
// Laten we dit testen:
static_assert(~one >= 0); // FAALT
Kijkend naar de diagnostiek geeft gcc ons: error: static assertion failed ... the comparison reduces to '(-2 >= 0)'
Wat is hier gebeurd? De passage in de standaard over integral promotion ([conv.prom]) maakt een speciaal onderscheid voor unscoped enumeration types zonder vastgesteld onderliggend type. Als het volledige bereik van waarden kan worden opgeslagen in een int, dan is dat het type waarnaar het wordt gepromoveerd. Daarna probeert hij unsigned int, en zo gaat dit door met long en long long.
Het verschil met gewone integer-types is dat het hier alleen kijkt naar het effectieve bereik van representeerbare waarden. Een enum die wordt ondersteund door een unsigned int zal dus gepromoveerd worden naar een int, ongeacht de normale conversie-rangschikking die dit voor integrale types zou verbieden. Omdat je de ingebouwde operator aanroept via ~one, wordt dit onvermijdelijk gepromoveerd naar int, het complement wordt berekend als -2, en de vergelijking tussen twee integers faalt. Als je echter eerst converteert naar het onderliggende type, zie je de asymmetrie: staticassert(~std::tounderlying(one) >= 0); slaagt.
Maar dit is slechts het halve werk. We moeten het hebben over het terugconverteren van het resultaat van onze bitwise-operatie naar het enum-type, en dat is waar UB in beeld komt. Een unscoped enum zonder gespecificeerd onderliggend type definieert zichzelf in termen van een geldig bereik van waarden, bepaald door zijn enumeratoren, onafhankelijk van het werkelijke type dat de compiler gebruikt. [dcl.enum] vertelt ons dat het waardenbereik van zo'n enum dat van een hypothetisch integer-type van breedte M is, waarbij M het minimum aantal bits is dat nodig is om alle enumeratoren te representeren.
Ter illustratie:
enum small { // Bereik 0..1, dus waardenbereik van een unsigned, one-bit integer.
a = 0,
b = 1,
};
enum medium { // Bereik 0..7, dus waardenbereik van een unsigned, three-bit integer.
c = 0,
d = 4,
e = 7
};
enum large { // Bereik -1..100, dus waardenbereik van een signed, eight-bit integer.
f = -1,
g = 10,
h = 100,
};
Hoewel je compiler deze enums waarschijnlijk opslaat als unsigned int of int, is slechts een subset van de representeerbare waarden geldig — die van de M-breedte integer. [expr.static.cast] in de standaard maakt duidelijk dat het ongedefinieerd gedrag (UB) is om een waarde buiten dat bereik naar het enum-type te casten.
static_cast<small>(1); // Well-defined
static_cast<small>(2); // UB
static_cast<medium>(3); // Well-defined: geen enumerator, maar binnen bereik unsigned 3-bit
static_cast<medium>(8); // UB
static_cast<large>(-128); // Well-defined
static_cast<large>(128); // UB
Dit is de gevarenzone. Bitwise-operatoren, in het bijzonder operator~, kunnen waarden produceren die buiten het gedefinieerde waardenbereik voor een enum vallen. Gebruikersdefinieerde operatoren die proberen deze waarde terug te casten naar het enum-type kunnen dus UB triggeren.
Wat betreft het eerdere voorbeeld met ~one: we zagen dat dit een waarde van -2 gaf, wat buiten het bereik van nums valt. Dit is waar integral promotion ons redt: one werd gepromoveerd naar int voordat het complement werd berekend, en het werd nooit teruggecast, waardoor UB werd voorkomen. We komen pas in gevaarlijk terrein met een gebruikersdefinieerde operator die het resultaat terugzet naar het oorspronkelijke enum-type.
Belangrijk is dat dit UB-risico alleen geldt voor unscoped "plain" enums zonder vastgesteld onderliggend type. Alle scoped enum types hebben een vast onderliggend type (standaard int), en alle unscoped enums die een vast onderliggend type specificeren, erven het waardenbereik van dat type.
Hoe dit te voorkomen in je eigen code
Stel dat je, net als P4313, bitmasker-operaties toevoegt aan enums voor je eigen code. Hoe zorg je ervoor dat je code correct en onverwarrend is?
Het vermijden van het bool-geval is eenvoudig: zorg dat het onderliggende type geen bool kan zijn, ofwel voor alle enums, of specifiek voor de complement-operator. Maar de standaard biedt geen handige type trait om specifiek een unscoped enum zonder vast onderliggend type te detecteren. Gelukkig kunnen we er zelf een maken:
template<typename E>
concept unfixed_enum = std::is_enum_v<E> && !requires { E{0}; };
[dcl.init.list] staat deze initialisatie vanuit een scalar alleen toe voor enums met een vast onderliggend type, en 0 is de enige waarde die representeerbaar is voor alle mogelijke enums. Vergeet std::isenumv niet, want er zijn veel types waarvoor T{0} niet is toegestaan die geen enums zijn.
Deze post richtte zich vooral op de bitwise-complement-operator, maar de UB-valstrik van een unfixed enum geldt ook voor de bitwise-left-shift-operator. Als je kiest voor beperkingen per operatie, vergeet dit dan niet.
Dit brengt ons terug naar P4313R1. Op het moment van schrijven beperkt het concept dat deze operatoren aanstuurt het type alleen tot een enumeratie-type met de annotatie [[=std::bitmask_type]]. Hierdoor is verwarrend gedrag mogelijk bij enum-types die gebaseerd zijn op bool, en potentieel UB bij plain enums zonder vast onderliggend type.
Zijstap: Hoe zit het met floating point?
Je vraagt je misschien af hoe de standaard de conversies van floating point-getallen naar deze bool-gebaseerde enums definieert. [expr.static.cast] vereist dat het gedrag equivalent is aan het eerst converteren van de floating point-waarde naar het onderliggende type van de enum, en deze vervolgens naar het enum-type te converteren; dit verwijst naar [conv.fpint], wat op zijn beurt verwijst naar [conv.bool]. Volgens de standaard zou dit betekenen dat elke floating point-waarde behalve exact 0.0 of -0.0 zou converteren naar boolean::TRUE, anders eindigen we bij boolean::FALSE.
Laten we klein beginnen:
enum class boolean : bool {
FALSE,
TRUE,
};
static_assert(static_cast<boolean>(0.0) == boolean::FALSE);
static_assert(static_cast<boolean>(0.5) == boolean::TRUE);
static_assert(static_cast<boolean>(2.5) == boolean::TRUE);
Zowel Clang als gcc wijzen deze code af. De fouten zijn dat (boolean)2.5e+0 geen constante expressie is, en dat static_cast<boolean>(0.5) == boolean::TRUE onjuist is en in plaats daarvan gelijk is aan boolean::FALSE. MSVC accepteert de bovenstaande code.
Maar laten we verder gaan en inspecteren wat er daadwerkelijk wordt opgeslagen. We schrijven een functie om het bitpatroon te onderzoeken:
void show(double d) {
const boolean e = static_cast<boolean>(d);
unsigned char byte {};
std::memcpy(&byte, &e, 1);
std::println("static_cast<boolean>({:7.1f}) stored byte {:3} required {}",
d, byte, (d != 0.0) ? 1 : 0);
}
int main() {
show(0.0);
show(0.5);
show(1.0);
show(2.5);
show(3.0);
show(256.0);
show(-2.5);
}
Deze code compileert zonder waarschuwing op alle drie de compilers, maar terwijl MSVC in alle gevallen het juiste doet, is het gedrag van gcc en Clang zorgwekkender. gcc snijdt de waarde altijd simpelweg af naar een integer en laadt dat bitpatroon in de resulterende boolean. Dus 2.5 wordt 2, wat een boolean oplevert met het bitpatroon 2. Clang doet hetzelfde in ongeoptimaliseerde builds, maar bij optimalisatie gaat de afgesneden waarde door de normale [conv.bool] transformatie en produceert het juiste antwoord voor alle niet-nul waarden buiten het bereik (-1, 1).
Dit is zorgwekkender dan alleen vreemde bitpatronen. Het geldige bereik van boolean is [0, 1]. Het is UB om objecten te lezen met bitpatronen buiten dit bereik. Wat gebeurt er als we deze booleans terugcasten naar bool? MSVC doet het juiste; gcc wijzigt de bitpatronen niet, waardoor we bools overhouden die buiten het bereik van het type vallen (en dus UB zijn om te lezen); en Clang is waar het echt leuk wordt.
Voor ongeoptimaliseerde builds zien we bij Clang (versies vóór 23) dat er simpelweg trunc(d) & 1 wordt uitgevoerd; oneven getallen zijn true en even getallen false. Clang 23 voert (trunc(d) % 256) != 0 uit. Geoptimaliseerde builds gedragen zich zoals eerder beschreven: afsnijden en vervolgens de juiste boolean-conversie uitvoeren.
Uiteindelijk komt dit tot een absurd hoogtepunt. Beschouw de volgende code:
#include <print>
enum class boolean : bool {
FALSE,
TRUE,
};
boolean make(double d) {
return static_cast<boolean>(d);
}
int main() {
const boolean e = make(2.5);
std::println("e == boolean::TRUE : {}", e == boolean::TRUE);
std::println("e == boolean::FALSE : {}", e == boolean::FALSE);
const bool b = static_cast<bool>(e);
std::println("b == true : {}", b == true);
std::println("b == false : {}", b == false);
std::println("b + 0 : {}", b + 0);
}
MSVC geeft het correcte antwoord. Clang doet het precies fout en denkt dat e FALSE is en b false is. gcc gaat nog een stap verder door een instantie van een enum te leveren die gelijk is aan al zijn enumeratoren, en een bool die noch true noch false is (tenzij je de optimizer aanzet, dan krijg je een bool die zowel true als false is).
Wat betreft oneindigheid (infinity) en NaN: deze worden door gcc en Clang als 0 gezien en worden FALSE en false; MSVC geeft 1 en dus TRUE en true. In positiever nieuws lijkt de Clang trunk dit voorbeeld inmiddels correct te behandelen.
Conclusie
Wat hebben we hiervan geleerd? Misschien dat zelfs verstandige en onomstreden pure-library-voorstellen zoals P4313 op hun hoede moeten zijn voor een footgun door een exotische C++ edge case. Of, in praktischere termen:
- Integral promotion is onvermijdelijk. Als je denkt dat je opereert op een integer die kleiner is dan
int, is de kans groot dat het ongemerkt eenintis geworden. - Het bitwise-complement van een
boolis altijdtrue, zelfs wanneer deze in een enum is verpakt. Vertrouw hier echter niet op, aangezien gcc een langdurige bug heeft die dit fout doet. - Unscoped enumeration types zonder vast onderliggend type hebben een bereik van geldige waarden dat kleiner kan zijn dan dat van het type dat daadwerkelijk door de compiler wordt gebruikt.
Groetjes,