The complement of true is true, except when it's false
true의 비트 보수는 true입니다, false가 되는 경우만 빼면
C++의 정수 승격과 enum 변환 규칙이 비트 연산에서 일으키는 까다로운 사례를 살펴봅니다. bool의 비트 보수와 열거형 변환에서 GCC·Clang·MSVC가 표준과 다르게 동작하는 사례, 고정 기반형이 없는 enum에 생기는 정의되지 않은 동작을 짚습니다.
- 주제
AI 요약
C++26 제안서 P4313R1은 [[=std::bitmask_type]] 주석으로 지정한 열거형(enum)에 비트 연산자를 제공하는 방안을 담고 있습니다. 글은 이 제안의 연산자 구현을 살펴보다가, 작은 정수형의 승격과 bool 변환, 열거형의 유효 값 범위가 맞물릴 때 생기는 예상 밖의 결과를 설명합니다.
작은 정수형은 연산 전에 int로 승격합니다
C++에서 short 같은 int보다 낮은 순위의 정수형에 비트 연산을 적용하면 피연산자는 먼저 int로 승격합니다. 연산 결과도 int입니다. 다시 원래 형으로 변환하면 대개 의도한 비트 패턴을 얻지만, 승격 자체는 피할 수 없습니다. bool도 예외가 아닙니다. true는 int 값 1로 승격되고, 그 비트 보수 ~1은 C++20의 2의 보수 규칙에 따라 -2가 됩니다. 이를 bool로 변환하면 0이 아닌 값이므로 true입니다. 따라서 static_cast<bool>(~true)는 true입니다.
bool 기반 enum과 컴파일러 차이
P4313R1의 비트 보수 연산자는 enum의 기반형으로 변환한 뒤 비트 연산을 하고, 결과를 다시 enum으로 변환하는 형태입니다. 기반형이 bool인 enum에서도 ~true와 같은 승격이 일어나므로 ~boolean::TRUE가 boolean::TRUE가 됩니다. 글은 GCC가 이 변환을 다르게 처리하는 사례를 제시합니다. bool 자체로 변환할 때는 0이 아닌 값을 true로 바꾸지만, bool 기반 enum으로 변환할 때는 결과의 최하위 비트만 남깁니다. 그래서 -2가 boolean::FALSE가 됩니다. Clang과 MSVC는 해당 사례에서 표준이 요구하는 결과를 냅니다. 저자는 GCC의 동작을 오랫동안 이어진 표준 준수 버그로 설명합니다.
고정 기반형이 없는 enum의 값 범위와 UB
범위가 지정되지 않은(unscoped) enum에 기반형을 명시하지 않으면, 컴파일러가 실제 저장 공간에 사용하는 정수형과 enum의 유효 값 범위가 다를 수 있습니다. 예를 들어 enumerator가 0부터 1까지인 enum은 저장형이 더 넓더라도 유효 범위가 1비트 정수의 범위에 머뭅니다. 표준에 따르면 유효 범위를 벗어난 값을 이런 enum으로 변환하면 정의되지 않은 동작(undefined behavior, UB)이 발생합니다.
정수 승격 규칙도 결과를 예상하기 어렵게 만듭니다. 고정 기반형이 없는 unscoped enum은 표현 가능한 값 전체가 들어가는 정수형을 찾을 때 실제 기반형의 범위가 아니라 enum의 유효 값 범위를 기준으로 승격합니다. 그래서 unsigned int를 기반으로 저장하는 enum도 int로 승격될 수 있습니다. 비트 보수 결과를 int로 유지하면 당장 enum 변환에 따른 UB는 없지만, 사용자 정의 연산자가 그 결과를 원래 enum으로 되돌리면 유효 범위를 벗어날 수 있습니다. 이 위험은 고정 기반형이 없는 unscoped enum에 해당하며, scoped enum과 기반형을 명시한 enum에는 적용되지 않습니다.
비트 연산자를 설계할 때 확인할 점
글은 기반형이 bool인 enum을 제약 조건으로 제외하고, 고정 기반형이 없는 enum도 조심해야 한다고 제안합니다. std::is_enum_v<E> && !requires { E{0}; }라는 concept로 고정 기반형이 없는 enum을 판별하는 방법도 소개합니다. 이때 enum 여부를 먼저 확인해야 하며, 0은 모든 enum에서 표현 가능한 값이라는 점을 이용합니다. 비트 시프트 연산도 결과를 enum으로 되돌리는 과정에서 같은 UB 위험을 만들 수 있습니다. P4313R1의 제약 조건은 주석이 붙은 enum인지만 확인하므로, 저자는 bool 기반 enum과 고정 기반형이 없는 enum을 추가로 제한할 필요가 있다고 설명합니다.
부동소수점 변환에서 드러나는 구현 차이
부동소수점 값을 bool 기반 enum으로 변환할 때 표준은 먼저 기반형으로 변환한 뒤 enum으로 변환하는 동작을 요구합니다. 0.0과 -0.0은 false, 그 밖의 값은 true가 되어야 합니다. 하지만 글에서 비교한 GCC와 Clang은 값을 정수로 자른 뒤 그 비트 패턴을 저장하는 등 서로 다른 결과를 냅니다. 그 결과 범위 밖의 비트 패턴을 가진 bool을 읽는 UB까지 이어질 수 있습니다. MSVC는 예시에서 표준에 맞는 결과를 내며, 글은 Clang trunk에서 일부 문제가 수정된 사례도 언급합니다.
Lobsters 반응
- @icefox — 정수 승격이 꽤 끔찍한 아이디어라는 점과 bool을 일반 정수처럼 다루는 방식이 문제라는 점을 보여주는 훌륭한 사례입니다.
- @nytpu — 적어도 bool을 1비트 정수로 제대로 다뤄야 합니다. 필요하다면 마스킹을 위해 명령어를 몇 개 더 생성하더라도 말입니다.
- @tomsmeding — “이 동작에 의존해서는 안 됩니다. GCC에 오래된 버그가 있습니다”라는 대목을 보니 C++을 그만 써야겠습니다. 글을 읽는 내내 몇 번이나 고개를 저었습니다. 훌륭합니다.
원문: dryperspective.github.io / 번역·요약: Trawling