true의 보수는 true입니다, false일 때만 빼고요

The complement of true is true, except when it's false

dryperspective.github.io ▲ 69 댓글 26 aw1621107

요약

C++에서 true의 비트 보수를 bool로 되돌리면 다시 true가 되고, 컴파일러마다 결과도 다릅니다.

매슈 테일러가 블로그 Substitution Failure에 쓴 글입니다. 열거형(enum)에 비트 연산을 자동으로 붙여 주는 표준 제안 P4313R1을 검토하다가, bool을 바탕 타입으로 쓰는 열거형의 함정을 짚었습니다.

왜 중요한가

  • 비트마스크 열거형을 표준에 넣으려면 bool 같은 특이한 바탕 타입을 미리 막아야 한다는 경고입니다.
  • 같은 코드가 GCC와 Clang·MSVC에서 다른 값을 내므로, 이식성을 믿고 쓴 코드가 조용히 틀릴 수 있습니다.

핵심 내용

  • int보다 작은 타입은 비트 연산 전에 int로 바뀌는 정수 승격(integral promotion)을 피할 수 없습니다.
  • true는 1로 승격되고 비트 보수(~, 모든 비트를 뒤집는 연산)를 거쳐 -2가 되며, bool로 바꾸면 0이 아니라서 다시 true가 됩니다.
  • bool 기반 열거형에 ~를 쓰면 Clang과 MSVC는 표준대로 TRUE를 내지만, GCC는 최하위 비트만 남겨 FALSE를 냅니다.
  • 바탕 타입을 정하지 않은 비범위 열거형(unscoped enum)은 열거자에 필요한 범위만 유효해서, ~ 결과를 되돌려 넣으면 미정의 동작이 됩니다.
  • 저자는 제안서가 bool 바탕 타입을 빼거나 범위 열거형(scoped enum)만 받도록 제한하자고 권했습니다.

HN 반응

  • GCC는 -Wall을 켜면 bool에 ~를 쓸 때 경고하므로, 경고를 켜 둔 개발자는 걱정할 필요가 없다는 의견이 나왔습니다.
  • true를 Forth·BASIC처럼 -1로 정했어야 한다는 주장에, 정수 크기마다 -1이 다르다는 반론과 부호 있는 1비트로 보면 된다는 재반론이 오갔습니다.

댓글

26개 표시 · 전체 26개
  1. Panzerschrek HN

    이런 예제가 있다고 해봅시다.

      bool Complement( bool x )
      {
          return ~x;
      }
    

    -Wall로 컴파일하면 GCC가 경고를 띄웁니다.

      test.cpp:3:10: warning: ‘~’ on an expression of type bool [-Wbool-operation]
        return ~x;
    

    C++이 이렇게 동작한다는 것 자체는 놀랍지만, 컴파일러 경고를 켜 두는 사람들은 문제가 없습니다.

  2. rep_lodsb HN

    Forth나 BASIC 같은 다른 언어처럼 true를 -1(모든 비트가 1)로 정의했어야 합니다.

  3. Panzerschrek HN

    -1은 정수 크기마다 다른 -1입니다. 정확히 어떤 -1을 원하시는 겁니까? 32비트? 64비트? int 크기(현재 구현에서 정의된 int)? 지금 방식은 단순합니다. 불리언 타입은 1비트 크기로 간주하고, 메모리에 저장할 때는 8비트로 올림해서 쓰면 됩니다.

  4. rep_lodsb HN

    bool이 부호 있는 1비트 타입이라면(즉 가질 수 있는 값이 0 아니면 -1이라면) 정수처럼 어떤 크기로든 확장할 수 있지 않나요?

    물론 지금은 그럴 수 없죠. 부호 없는 bool이 C 언어 표준에 박혀 있고, 심지어 프로세서(x86의 SETcc 명령어)에도 박혀 있으니까요. 반면 "char"가 부호 있는지 여부는 아직도 구현 정의로 알고 있는데 말이죠...

  5. wat10000 HN

    간단한 해결책이 있습니다. 프로그래머가 signed bool이라고 써서 직접 선택하게 하면 됩니다.

  6. mrkeen HN

    bool의 크기에 대해서는 기대하는 바가 있으시군요(단순히 1비트이면서 동시에 8비트라고요).

    그럼 값에 대해서는 어떤 기대를 하고 계십니까?

  7. Panzerschrek HN

    bool은 논리적으로 1비트여야 하고, 메모리에 저장할 때는 최하위 비트만 쓰고 나머지는 쓰레기 값이어도 되어야 합니다. 이렇게 해야 컴파일러가 최적화할 여지를 최대한 가질 수 있습니다. 특정 비트 패턴을 강제로 쓰게 하면 최적이 아닌 코드가 생성될 수 있습니다.

  8. adrian_b HN

    bool은 1비트이지만, 그 비트를 부호 있는 것으로 정의할 수도 있고 부호 없는 것으로 정의할 수도 있습니다.

    bool을 부호 없는 것으로 정의하면, 어떤 크기의 정수로 캐스팅하든 false는 0, true는 1이 됩니다(작은 부호 없는 정수를 큰 부호 없는 정수로 바꿀 때 쓰는 표준 영 확장 연산을 사용합니다).

    bool을 부호 있는 것으로 정의하면, 어떤 크기의 정수로 캐스팅하든 false는 0, true는 -1(즉 모든 비트가 1인 패턴)이 됩니다(작은 부호 있는 정수를 큰 부호 있는 정수로 바꿀 때 쓰는 표준 부호 확장 연산을 사용합니다).

    LSB 말고 나머지 비트는 무시하도록 bool을 정의하면 대부분의 프로세서에서 성능이 떨어집니다. 거의 모든 명령어 집합에서 정수가 널(null)인지 아닌지 검사하는 쪽이 특정 비트의 값을 검사하는 쪽보다 효율적이기 때문입니다.

    비트 하나만 쓰고 나머지를 무시하면서 효율까지 챙기는 방법은 불리언을 최상위 비트, 즉 부호 있는 정수의 부호 비트에 저장하는 것뿐입니다. 부호를 검사하는 일은 보통 값이 널인지 검사하는 일만큼 간단하니까요. 이 관례를 쓴다면 불리언 결과는 false가 0, true가 -1이 되겠지만, 입력 인자로 받을 때는 음수면 true, 음이 아니면 false가 됩니다.

  9. Lumich HN

    « 정수가 널(null)인지 아닌지 검사하는 »

    이건 영(zero)인지 아닌지라고 쓰는 게 더 정확하지 않나요?

  10. adrian_b HN

    널(null)과 영(zero)은 같은 말이지만, 형용사로 쓸 때는 null이, 명사로 쓸 때는 zero가 더 낫습니다.

    같은 개념에 단어가 둘 있는 이유는 "null"은 라틴어에서, "zero"는 산스크리트어에서 아랍어를 거쳐 왔기 때문입니다.

    어원상 "null"은 "하나도 없다"는 뜻입니다("not one"의 축소형이라서요).

    일부 프로그래밍 언어에서 "null"을 "정의되지 않음", "해당 없음", "없음" 같은 뜻으로 쓰는 것은 잘못입니다. 그런 뜻에는 LISP처럼 NIL이 맞습니다(NIL은 아무것도 없다는 뜻입니다).

    LISP는 NIL을 써서 옳은 선택을 했지만, 나중에 어떤 값이 NIL인지 검사하는 술어(predicate)를 NULL이라고 부르는 실수를 저질렀습니다. 그 술어는 "is_nil" 같은 이름이었어야 합니다.

    C.A.R. 호어가 "null"이라는 말을 도입했을 때, 그는 이 말을 참조, 즉 포인터에 적용했지 그 포인터가 가리키는 대상에 적용하지 않았습니다. 그래서 값이 영인 "null" 포인터는 NIL, 즉 아무것도 없는 것을 가리킵니다. 이는 가리키는 대상 쪽에 NIL을 나타내는 특수한 값을 따로 예약해 두는 인코딩을 고안하는 방식(부동소수점의 Not-a-Number 값 같은 것)의 대안이기도 합니다.

  11. ninalanyon HN

    설마 그 얘기를 다 지어내신 건 아니겠죠. 그런데 어떤 권위 있는 자료를 참고하셨고, 어떤 상황에서 어떤 청중이 모든 점에 바로 동의할까요?

    영국 영어에서 nil은 영을 뜻합니다. 축구에서 nil-nil 무승부라고 하는 것처럼요. 이때는 사실상 서수에 가깝고, 아무것도 없음이 아니라 하나도 없음을 뜻합니다.

  12. thechao HN

    이 얘기를 들으니 제가 제일 좋아하는 C 난해 퀴즈가 생각나네요. 이런 쓰레기 같은 X 툴체인에서는 TRUE와 FALSE가 무슨 값일까요? 제가 제일 좋아했던 답은 0=TRUE, 2=FALSE였어요. 그런 걸 생각해 낸 그 괴짜랑은 꼭 악수 한번 해보고 싶네요.

  13. wat10000 HN

    적어도 x86-64와 ARM64에서는 레지스터의 임의의 비트를 명령어 하나로 검사할 수 있습니다.

  14. adrian_b HN

    비트 검사도 명령어 하나로 된다고 해서 똑같이 효율적이라는 뜻은 아닙니다.

    x86-64에서 비트 값을 검사하는 방법은 두 가지입니다. 비트 검사 명령어(BT)를 쓰면, 이 명령어는 레지스터나 메모리 값이 널인지 검사하는 것보다 길고 느립니다.

    마스크 검사 명령어(TEST)를 쓰면 빠르긴 하지만, 마스크용 즉치 상수가 들어가기 때문에 명령어가 상당히 깁니다. 명령어가 길면 여러 병목에 걸릴 때 속도가 떨어질 수도 있습니다. 예를 들어 클록 사이클당 가져올 수 있는 최대 바이트 수, 명령어 캐시나 마이크로 연산 캐시의 용량 같은 것들입니다.

    게다가 값이 널인지 검사할 때는 명령어가 한 개도 필요 없는 경우가 많습니다. 어떤 식을 계산한 결과가 그 값이라면, 플래그 레지스터에 이미 그 값이 널인지(그리고 부호가 무엇인지)가 저장돼 있기 때문입니다.

    ARM Aarch64에서는 레지스터의 비트를 검사하는 명령어의 점프 범위가 레지스터 전체가 널인지 검사하는 명령어보다 훨씬 좁습니다. 그래서 비트를 검사하려면 실행을 이어 갈 목적지까지 닿도록 점프 명령어를 하나 더 끼워 넣어야 할 수도 있습니다.

  15. Joker_vD HN

    예전에는 숫자의 모든 비트가 0인지 검사하는 것이 부호 비트 하나를 검사하는 것보다 느렸습니다. MIPS를 처음 설계할 때도 헤네시와 팀원들이 BEQZ/BNEZ를 의도한 파이프라인에 맞춰 충분히 빠르게 만드는 데 애를 먹었습니다.

  16. adrian_b HN

    부호 비트를 검사할 때는 그 비트에서 플래그까지 배선 하나만 있으면 되지만, 레지스터가 0인지 검사하려면 비트 수만큼 입력이 있는 넓은 OR 게이트가 필요하기 때문입니다.

    CMOS에서는 그렇게 넓은 OR 게이트를 만들 수 없어서 더 좁은 게이트를 여러 단 이어 붙여 합성해야 하고, 그러면 지연이 몇 단계나 늘어납니다.

    최신 CPU 기술에서는 영 플래그를 만드는 속도가 문제가 되지 않지만, 훨씬 느린 FPGA로 설계할 때는 부호를 검사하는 편이 넓은 영 검사보다 싸다는 점을 알아 두면 유용합니다.

    많은 CPU에는 감소 후 0이 아니면 점프 같은 루프용 명령어가 있습니다(x86-64의 LOOP가 그렇습니다). FPGA에 단순한 CPU를 구현할 때는 이 명령어를 증가 후 음수면 점프, 감소 후 음수가 아니면 점프라는 명령어 두 개로 바꾸는 편이 더 싸고 빠릅니다(루프 카운터를 인덱스 레지스터로도 쓰면서 배열을 순방향으로도 역방향으로도 접근하려면 이 두 명령어가 모두 있는 것이 좋습니다).

    FPGA에서 카운터를 만들 때도 마찬가지입니다. 카운트가 0에 도달했는지 검사하는 대신 부호 비트로 카운트의 끝을 판단하면 더 높은 주파수에서 셀 수 있습니다.

  17. rep_lodsb HN

    gcc가 (적어도 이 경우 하나에서는) 그렇게 하긴 하는데, 기사에서 지적하듯이 그건 표준을 따르지 않는 방식입니다.

  18. Joker_vD HN

    예전에 Clang과 GCC가 어떤 정수 타입을 반환할 때 x64 레지스터의 상위 부분에 무엇이 들어 있어야 하는지(0인지 쓰레기 값인지)를 두고 서로 의견이 달랐던 걸로 어렴풋이 기억합니다. x64용 Sys V ABI를 정의한 PDF가 그런 사소한 세부 사항을 빼먹었거든요. 그래서 둘 다 같은 ABI를 따른다고 주장하는 두 컴파일러가 만든 오브젝트를 링크하면 제대로 동작하지 않는 실행 파일이 나왔습니다.

    특정 비트 패턴을 강제로 쓰게 하면 최적이 아닌 코드가 생성될 수 있습니다.

    그래서요? "return 42;"를 "mov eax, 42; ret"로 컴파일하도록 강제하는 것도 최적이 아닌 코드를 낳습니다. rax에 이미 들어 있는 값을 그대로 반환하는 평범한 "ret"이 최적이니까요. 42라는 정확한 비트 패턴은 만들어 주지 못하겠지만, 효율이 좋아진다면 그 정도는 작은 대가 아닌가요?

  19. wat10000 HN

    메모리 안의 표현 방식은 완전히 다른 문제입니다. 언어가 true의 정수 값은 -1이라고 정하면서도 저장은 비트 하나로 하게 만들 수 있습니다.

  20. wpollock HN

    C++가 bool을 정수형으로 정의한 이유는 이해하지만, 그렇지 않았다면 더 좋았을 수도 있습니다. 요즘 일부 언어에서는 식 안에서 불리언을 숫자와 섞어 쓸 수 없는데, 저는 그게 전혀 문제가 되지 않습니다.

  21. s-zeng HN

    특성(characteristic)이 2인 체에서는 모든 원소가 자기 자신의 음수입니다. 그러니 열거형의 직관에 반하는 예외 동작은 차치하고, 겉보기에는 이치에 맞는 얘기입니다.

  22. yorwba HN

    기사는 음수가 아니라 보수(complement)에 관한 글입니다.

  23. nine_k HN

    C++는 어떤 면에서 Perl의 진정한 정신적 후계자예요. 무엇이든 다 표현할 수 있고, 문법은 난해하고, 논리도 난해하죠. 물론 대부분은 보이는 대로 나옵니다. 보이는 게 안 나오는 순간이 오기 전까지는요.

  24. pwdisswordfishq HN

    [[=std::bitmask_type]]을 쓰면, switch 문이 가능한 모든 플래그 조합을 다루지 않을 때도 switch가 완전하지 않다는 컴파일러 경고가 뜰지 궁금하네요.

  25. glownagger HN

    저는 어트리뷰트(attribute)가 호출 규약 같은 컴파일러 고유 의미를 위해 따로 남겨 둔 거라고 생각했거든요. 그런데 여기서는 문법적 의미가 있는 언어 기능의 스트로핑(stropping)용으로 쓰이고 있네요!

  26. leni536 HN

    이건 어트리뷰트가 아니라 애너테이션(annotation)입니다. 애너테이션은 [[=로 시작합니다.

    https://www.open-std.org/jtc1/sc22/wg21/docs/papers/2025/p3394r4.html

Hacker News에서 보기 ↗