TECH 으로 돌아가기
TECH HACKER NEWS 오늘 8분 읽기 24 READS

C++ 비트 연산의 함정: ~true가 다시 true가 되는 이유

C++ 비트 연산의 함정: ~true가 다시 true가 되는 이유
SOURCE IMAGE · HACKER NEWS

C++26 표준화를 노리는 제안 P4313R1은 열거형(enum)에 비트마스크 연산을 자동으로 붙여 주는 기능을 담고 있다. [[=std::bitmask_type]]라는 어노테이션을 enum에 달아 두면 AND·OR·NOT 같은 비트 연산자를 사용자가 직접 작성하지 않아도 되도록 컴파일러가 알아서 제공한다. 편의성은 분명하지만, 이 제안을 들여다본 한 개발자는 그동안 잘 드러나지 않던 C++ 정수 연산의 어두운 뒷면을 함께 끄집어냈다. 비트마스크처럼 단순해 보이는 기능조차, 바닥에 깔린 변환 규칙 때문에 직관과 정반대의 결과를 낼 수 있다는 것이다.

피할 수 없는 정수 승격

C++ 컴파일러는 wchar_t, char8_t~char32_t, 그리고 bool처럼 우리가 보통 정수로 여기지 않는 타입에 대해서도 비트 연산을 기본 연산으로 수행한다. 문제는 a | b처럼 int보다 작은 정수 타입을 다룰 때다. 이때 언어는 a와 b의 비트 패턴을 그대로 다루지 않고, 두 값을 먼저 int로 승격(integral promotion)한 뒤 그 int끼리 연산하고 결과도 int로 돌려준다. 대부분의 경우 결과를 원래 타입(예: short)으로 되돌리면 불필요한 바이트가 잘려 나가 올바른 값이 남으므로 무해하다. 그래서 조용한 축소 변환 버그를 막으려면 비트 연산 결과를 원래 타입으로 static_cast하는 습관이 권장된다. 핵심은 이 승격이 선택이 아니라는 점이다. C++의 다른 많은 영역과 달리, 개발자에게 거부권이 없다.

bool을 특히 위험하게 만드는 두 번째 요소는 불리언 변환이다. 표준 [conv.bool]에 따르면 bool로의 변환은 값을 잘라내는 대신 0은 false로, 0이 아닌 모든 값은 true로 명시적으로 바꾼다. 이때 원래 저장돼 있던 비트 패턴은 버려지고 정수값 1을 가진 true로 대체된다. 그 결과 ~true를 계산해 다시 bool로 캐스팅하면, true가 int로 승격돼 보수가 계산되고 그 0이 아닌 값이 다시 true로 변환된다. 즉 C++에서 static_cast(~true) == true가 성립한다. 참의 보수가 다시 참인 셈이다.

컴파일러마다 다른 답

enum은 bool을 포함한 어떤 정수 타입이든 기반 타입으로 쓸 수 있으므로, bool 기반 enum에 보수 연산자를 적용하면 위 혼란이 그대로 전이된다. 더 까다로운 것은 컴파일러 간 불일치다. Clang과 MSVC는 표준대로 네 가지 보수-캐스팅 조합을 모두 true로 평가하지만, gcc는 타입 이름이 명시적으로 bool이 아닌 enum에 대해서는 불리언 변환 대신 최하위 비트로 잘라낸다. 그래서 짝수는 FALSE, 홀수는 TRUE가 된다. 이는 bool을 직접 다룰 때는 나타나지 않고 enum에서만 발생하는, 오래 지속된 gcc의 적합성 버그로 지목됐다.

미정의 동작이 들어오는 길

정말 위험한 지점은 고정 기반 타입이 없는 비범위(unscoped) enum이다. [conv.prom]은 이런 enum을 일반 정수와 다르게 취급해, 열거자들이 표현 가능한 유효 범위만 보고 int→unsigned int→long 순으로 승격 타입을 고른다. 한편 [dcl.enum]은 이런 enum의 유효 값 범위를, 모든 열거자를 담는 데 필요한 최소 비트 폭 M의 가상 정수 타입 범위로 정의한다. 컴파일러가 실제로는 unsigned int로 저장하더라도, 쓸 수 있는 값은 그 M비트 범위의 부분집합뿐이다. [expr.static.cast]는 이 범위를 벗어난 값을 enum으로 캐스팅하는 것을 미정의 동작으로 규정한다. 보수 연산자가 범위 밖 값을 만들어 내고, 사용자 정의 연산자가 그 값을 다시 enum으로 되돌리려는 순간 UB가 발생한다. 흥미롭게도 ~one 예시에서 UB가 터지지 않은 것은 정수 승격 덕분이다. one이 먼저 int로 승격돼 보수가 계산됐을 뿐 enum으로 되돌려지지 않았기 때문이다. 이 위험은 고정 기반 타입이 없는 비범위 enum에만 해당한다. 모든 범위(scoped) enum은 미지정이어도 int라는 고정 기반 타입을 가지며, 기반 타입을 명시한 비범위 enum은 그 타입의 범위를 물려받는다.

직접 비트마스크를 만든다면

그래서 P4313처럼 enum에 비트마스크 연산을 손수 붙일 때는 방어가 필요하다. bool 기반을 막는 것은 간단하다. 기반 타입이 bool이 아니도록 제약을 걸면 된다. 그러나 표준에는 '고정 기반 타입이 없는 비범위 enum'을 집어내는 타입 특질이나 리플렉션 함수가 없다. 대안으로 [dcl.init.list]를 이용해 T{0} 초기화가 가능한지로 판별하는 기법이 있다. 이 초기화는 고정 기반 타입을 가진 enum에서만 허용되고, 0은 모든 enum에서 표현 가능한 유일한 값이기 때문이다. 다만 std::is_enum_v를 함께 확인해야 한다. T{0}이 성립하는 enum 아닌 타입이 많기 때문이다. 같은 UB 함정이 비트 보수뿐 아니라 좌시프트 연산자에도 적용된다는 점도 잊지 말아야 한다. 현재 P4313R1의 제약은 단지 어노테이션이 붙은 enum이기만 하면 통과시키므로 bool 기반의 혼란과 비범위 enum의 UB를 모두 허용한다. 보수적으로 std::is_scoped_enum으로 제약하거나 기반 타입에서 bool을 배제하는 방안이 거론되며, 글쓴이는 P4313 저자들에게 이 제약 추가를 제안할 뜻을 밝혔다.

부동소수점 변환은 또 다른 층의 혼란을 드러낸다. [expr.static.cast]는 bool 기반 enum으로의 부동소수점 캐스팅을 기반 타입으로 먼저 변환한 뒤 enum으로 변환하는 것과 같다고 규정하고, 결국 [conv.bool]로 이어진다. 표준대로라면 0.0과 -0.0을 제외한 모든 부동소수점 값은 TRUE가 돼야 한다. 하지만 실제로는 Clang과 gcc가 static_cast(0.5)를 FALSE로 평가하는 반면 MSVC만 올바르게 동작한다. 저장되는 비트 패턴을 들여다보면 문제는 더 깊다. gcc는 2.5를 2로 잘라 그 비트 패턴을 그대로 싣고, Clang은 최적화 여부에 따라 결과가 갈린다. boolean의 유효 범위는 [0, 1]이며 그 밖의 비트 패턴을 읽는 것 자체가 UB다. 표준의 문구는 명확해 보여도 주요 컴파일러들의 구현이 이렇게 엇갈린다는 사실은, 비트마스크 기능을 표준화하기 전에 이 구석들을 제약으로 틀어막아야 할 이유를 잘 보여 준다.

SOURCE · HACKER NEWS
원문 전체 보기 → https://dryperspective.github.io/posts/complement-of-true/
SHARE
NEXT · CHOOSE

변화를 읽었다면,
내가 만들 수익 구조를 고릅니다.

정보를 더 모으는 데서 멈추지 않고, 광고·외주·판매·중개·구독 중 내 상황에 맞는 출발점을 정해보세요.

21가지 수익 구조 살펴보기 →
처리 중...