비트 연산자
정수의 비트를 AND·OR·XOR·NOT과 시프트로 조작할 때 적용되는 승격, 너비, 마스크 계약을 익힙니다.
비트 연산자는 정수의 이진 표현에서 같은 위치의 비트를 결합하거나 자리를 옮깁니다. 논리 연산자 &&, ||, !처럼 피연산자를 참·거짓 하나로 줄이지 않으며, 기본 제공 &, |, ^에는 단락 평가도 없습니다.
비트 패턴 자체가 데이터 계약인 플래그, 프로토콜 필드, 장치 레지스터 같은 곳에서는 유용합니다. 일반적인 상태 모델이라면 이름 있는 필드나 자료구조가 더 읽기 쉬울 수 있습니다.
피연산자와 변환 계약
기본 제공 비트 연산자의 피연산자는 정수형 또는 범위 없는 열거형이어야 합니다. float와 double에는 적용할 수 없습니다.
- 이항
&,|,^는 일반 산술 변환을 수행한 공통 타입에서 각 비트를 결합합니다. - 단항
~와 시프트의 피연산자에는 정수 승격이 적용됩니다. unsigned char처럼int보다 작은 타입도 연산 전에 대개int로 승격됩니다.- 범위 있는
enum class는 기본 제공 정수 비트 연산에 암시적으로 참여하지 않습니다. 명시적인 표현이나 연산자 오버로드로 계약을 정의해야 합니다.
예를 들어 4비트만 적어 보면 5는 0101, 3은 0011입니다.
| 연산자 | 자리별 규칙 | 0101과 0011 예시 |
|---|---|---|
& | 둘 다 1인 자리만 1 | 0001 |
| | 하나 이상 1이면 1 | 0111 |
^ | 서로 다른 자리만 1 | 0110 |
~ | 승격된 피연산자의 모든 자리를 반전 | ~0101은 실제 승격 타입의 전체 너비에서 계산 |
작은 정수 타입의 ~ 결과를 원래 너비로 자동 해석하면 안 됩니다.
#include <cstdint>
#include <type_traits>
std::uint8_t small = 5;
auto inverted = ~small;
static_assert(std::is_same_v<decltype(inverted), int>);
// uint8_t가 제공되는 구현에서는 정확히 8비트입니다.
auto low_eight_bits = static_cast<std::uint8_t>(inverted); // 250고정 너비 비트 패턴을 다룰 때는 std::uint32_t처럼 의도가 드러나는 부호 없는 타입을 우선 고려하세요.
#include <cstdint>
std::uint32_t a = 0b0101;
std::uint32_t b = 0b0011;
auto common = a & b; // 0001
auto combined = a | b; // 0111
auto different = a ^ b; // 0110
std::uint32_t inverted = static_cast<std::uint32_t>(~a); // 승격 뒤 32비트로 복귀시프트 연산자
E1 << n과 E1 >> n은 곱셈·나눗셈을 빠르게 표현한 것이 아니라, 승격된 왼쪽 피연산자의 고정 너비에서 비트 자리를 옮기는 연산입니다.
연산 전에 다음 계약을 확인합니다.
- 두 피연산자는 정수형 또는 범위 없는 열거형이고 정수 승격을 거칩니다.
- 결과 타입은 승격된
E1의 타입입니다. - 시프트 횟수
n은 음수가 아니며 결과 타입의 너비N보다 작아야 합니다. 그렇지 않으면 동작이 정의되지 않습니다. - 현재 표준 규칙에서 왼쪽 시프트 값은
E1 × 2ⁿ과2ᴺ을 법으로 합동인 결과이며 빈자리는0으로 채웁니다. - 오른쪽 시프트 값은
E1 / 2ⁿ을 음의 무한대 방향으로 반올림합니다. 부호 있는 정수의 오른쪽 시프트는 부호 확장을 수행합니다.
따라서 이 교재에서 실행하는 C++20 코드의 -5 >> 1은 -3입니다. 오래된 C++ 버전을 설명하는 자료에는 음수의 오른쪽 시프트를 구현 정의라고 적은 경우가 있으므로, 대상 언어 버전을 확인해야 합니다.
#include <cstdint>
#include <limits>
std::uint32_t value = 10;
constexpr unsigned count = 2;
static_assert(count < std::numeric_limits<std::uint32_t>::digits);
std::uint32_t left = value << count; // 40
std::uint32_t right = value >> 1; // 5작은 타입은 시프트 전에 승격될 수 있으므로, 결과를 다시 좁은 타입에 저장할 때 별도의 변환이 일어난다는 점도 확인해야 합니다.
BUILT-IN SHIFT CONTRACT
시프트는 승격된 타입과 유효한 횟수부터 확인한다
연산 기호보다 먼저 정수 승격 뒤의 왼쪽 타입과 시프트 횟수 범위를 확인합니다.
두 연산의 공통 사전 점검
- 1 · 승격
- 두 피연산자는 정수형 또는 범위 없는 열거형이며 정수 승격을 거칩니다.
- 2 · 횟수
0 ≤ n < N. 음수이거나 승격된 왼쪽 타입의 너비N이상이면 정의되지 않습니다.- 3 · 결과
- 결과 타입은 승격된 왼쪽 피연산자
E1의 타입입니다.
E1 << n · 왼쪽 시프트
빈 낮은 자리는 0으로 채웁니다.
- 값 계약
E1 × 2ⁿ과2ᴺ을 법으로 합동인 결과를 만듭니다.- unsigned 예
10u << 2→40u
E1 >> n · 오른쪽 시프트
낮은 자리 비트를 버리고 현재 표준의 나눗셈 규칙을 적용합니다.
- 값 계약
E1 / 2ⁿ을 음의 무한대 방향으로 반올림합니다.- signed 예
-5 >> 1→-3· 부호 확장
E1은 n보다 먼저 평가됩니다. 시프트를 성능 요령으로 선택하지 말고, 고정 너비의 비트 이동이 의도일 때 사용합니다.
마스크와 플래그
마스크는 관심 있는 자리를 1로 표시한 값입니다. 서로 겹치지 않는 단일 플래그는 보통 1u << n 형태로 정의합니다.
#include <cstdint>
using Flags = std::uint32_t;
constexpr Flags READ = Flags{1} << 0;
constexpr Flags WRITE = Flags{1} << 1;
constexpr Flags DEBUG = Flags{1} << 2;
Flags flags = READ | DEBUG; // 0101
flags |= WRITE; // 설정: 0111
flags &= static_cast<Flags>(~READ); // 해제: 0110
flags ^= DEBUG; // 토글: 0010
bool has_any = (flags & (READ | WRITE)) != 0;
bool has_all = (flags & (READ | WRITE)) == (READ | WRITE);(flags & mask) != 0은 마스크 중 하나 이상이 켜졌는지 묻고, (flags & mask) == mask는 마스크의 모든 비트가 켜졌는지 묻습니다. 단일 비트 마스크에서는 두 질문의 결과가 같지만, 여러 비트를 합친 마스크에서는 다릅니다.
~mask도 승격 규칙의 영향을 받습니다. 좁은 부호 없는 타입은 ~ 전에 더 넓은 int로 승격될 수 있으므로, 반전 결과를 static_cast<Flags>로 되돌리면 마스크에 적용할 너비가 분명해집니다.
마스크 연산은 바꾸는 자리와 검사 조건을 분리한다
원 도표의 독립 0101 실험표와 별도로 본문 실제 코드의 누적 상태 0101→0111→0110→0010을 보여 줄 수 있다. 표의 독립 결과를 연속 상태로 오인하지 않도록 구분한다.
FLAG MASK LEDGER
비트 변경과 any·all 판정은 별개 연산이다
마스크가 가리키는 비트를 어떻게 바꿀지와, 검사 결과를 어떤 조건으로 판정할지를 따로 읽습니다. 각 예시는 flags=0101에서 독립적으로 시작합니다.
설정·해제·토글은 각각 flags=0101에서 독립적으로 시작한다. 검사에서는 0101 & 0011 = 0001을 any와 all의 서로 다른 질문으로 나눈다.
관계도 해설과 예제 원자료
타입과 비트 배치 계약
flags와 mask는 필요한 너비를 가진 같은 unsigned 타입으로 둡니다.
좁은 unsigned도 ~ 전에 승격될 수 있으므로 static_cast<Flags>(~mask)로 선택한 너비에 되돌립니다.
READ=0001 · WRITE=0010 · DEBUG=0100 · 현재 flags=0101
| 목적 | 식 · 마스크 의미 · flags=0101 예 |
|---|---|
| 설정 | flags |= mask — 1인 자리를 켭니다.예: |
| 해제 | flags &= static_cast<Flags>(~mask) — 승격 후 반전한 값을 Flags 너비로 되돌려 해당 자리만 0으로 만듭니다.예: |
| 토글 | flags ^= mask — 1인 자리의 상태를 반전합니다.예: |
| 하나라도 확인 | (flags & mask) != 0 — 겹치는 비트가 하나 이상인지 봅니다.예: |
| 모두 확인 | (flags & mask) == mask — 요구한 비트가 전부 켜졌는지 봅니다.예: |
상태 변경
|= mask- 설정 ·
0101 | 0010 → 0111 &= static_cast<Flags>(~mask)- 해제 · 선택한 너비에서
0101 & ~0001 → 0100 ^= mask- 토글 ·
0101 ^ 0100 → 0001
조건 검사
(flags & mask) != 0- 하나라도 켜졌는지 확인 ·
0101 & 0011 = 0001·0001 != 0 → true (flags & mask) == mask- 모두 켜졌는지 확인 ·
0101 & 0011 = 0001·0001 == 0011 → false
단일 비트 마스크에서는 any와 all이 같아 보이지만, 결합 마스크에서는 결과가 달라집니다. 여러 실행 흐름이 같은 값을 바꾸면 별도의 동기화나 원자 연산이 필요합니다.
이 예의 조건
- 본문과 원 도표에 제시된 사례의 입력·조건을 사용합니다. 설명용 수치나 독립 비교 경로는 각 단계에 표시합니다.
- 현재 상태·값과 전환 근거의 위치는 모든 단계에서 같습니다. 조건이 맞지 않거나 실행되지 않은 경로를 성공한 동작으로 해석하지 않습니다.
관계도의 보충 설명
| 범위 | 원문 |
|---|---|
| 본문·주석 | 초깃값 |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 비트 초깃값 본문의 누적 변경 예는 READ와 DEBUG가 켜진 0101에서 시작한다. |
|
|
| WRITE 설정·READ 해제 WRITE를 켠 뒤 READ를 끈다. |
|
|
| DEBUG 토글 DEBUG 비트를 XOR로 뒤집어 최종 0010을 얻는다. |
|
|
마스크 연산은 바꾸는 자리와 검사 조건을 분리한다
원 도표의 독립 0101 실험표와 별도로 본문 실제 코드의 누적 상태 0101→0111→0110→0010을 보여 줄 수 있다. 표의 독립 결과를 연속 상태로 오인하지 않도록 구분한다.
FLAG MASK LEDGER
비트 변경과 any·all 판정은 별개 연산이다
마스크가 가리키는 비트를 어떻게 바꿀지와, 검사 결과를 어떤 조건으로 판정할지를 따로 읽습니다. 각 예시는 flags=0101에서 독립적으로 시작합니다.
설정·해제·토글은 각각 flags=0101에서 독립적으로 시작한다. 검사에서는 0101 & 0011 = 0001을 any와 all의 서로 다른 질문으로 나눈다.
관계도 해설과 예제 원자료
타입과 비트 배치 계약
flags와 mask는 필요한 너비를 가진 같은 unsigned 타입으로 둡니다.
좁은 unsigned도 ~ 전에 승격될 수 있으므로 static_cast<Flags>(~mask)로 선택한 너비에 되돌립니다.
READ=0001 · WRITE=0010 · DEBUG=0100 · 현재 flags=0101
| 목적 | 식 · 마스크 의미 · flags=0101 예 |
|---|---|
| 설정 | flags |= mask — 1인 자리를 켭니다.예: |
| 해제 | flags &= static_cast<Flags>(~mask) — 승격 후 반전한 값을 Flags 너비로 되돌려 해당 자리만 0으로 만듭니다.예: |
| 토글 | flags ^= mask — 1인 자리의 상태를 반전합니다.예: |
| 하나라도 확인 | (flags & mask) != 0 — 겹치는 비트가 하나 이상인지 봅니다.예: |
| 모두 확인 | (flags & mask) == mask — 요구한 비트가 전부 켜졌는지 봅니다.예: |
상태 변경
|= mask- 설정 ·
0101 | 0010 → 0111 &= static_cast<Flags>(~mask)- 해제 · 선택한 너비에서
0101 & ~0001 → 0100 ^= mask- 토글 ·
0101 ^ 0100 → 0001
조건 검사
(flags & mask) != 0- 하나라도 켜졌는지 확인 ·
0101 & 0011 = 0001·0001 != 0 → true (flags & mask) == mask- 모두 켜졌는지 확인 ·
0101 & 0011 = 0001·0001 == 0011 → false
단일 비트 마스크에서는 any와 all이 같아 보이지만, 결합 마스크에서는 결과가 달라집니다. 여러 실행 흐름이 같은 값을 바꾸면 별도의 동기화나 원자 연산이 필요합니다.
이 예의 조건
- 본문과 원 도표에 제시된 사례의 입력·조건을 사용합니다. 설명용 수치나 독립 비교 경로는 각 단계에 표시합니다.
- 현재 상태·값과 전환 근거의 위치는 모든 단계에서 같습니다. 조건이 맞지 않거나 실행되지 않은 경로를 성공한 동작으로 해석하지 않습니다.
관계도의 보충 설명
| 범위 | 원문 |
|---|---|
| 본문·주석 | 초깃값 |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 비트 초깃값 본문의 누적 변경 예는 READ와 DEBUG가 켜진 0101에서 시작한다. |
|
|
| WRITE 설정·READ 해제 WRITE를 켠 뒤 READ를 끈다. |
|
|
| DEBUG 토글 DEBUG 비트를 XOR로 뒤집어 최종 0010을 얻는다. |
|
|
본문 코드는 0101→0111→0110→0010으로 같은 값을 차례로 변경합니다. 반면 비교표의 설정·해제·토글은 각각 최초 0101에서 독립적으로 시작합니다. 두 결과가 다르면 연산자가 아니라 시작 상태가 같은지 먼저 확인합니다. 이진 표기에서 오른쪽 끝은 2⁰ 자리이며, READ·WRITE·DEBUG는 서로 다른 한 자리만 켭니다.
예시
아래는 필요한 고정 너비 타입 std::uint32_t가 제공되는 구현에서 실행합니다. 먼저 출력 세 줄을 비트 상태로 예측해 보세요.
#include <cstdint>
#include <iostream>
int main() {
using Flags = std::uint32_t;
constexpr Flags read{1};
constexpr Flags write{2};
constexpr Flags debug{4};
Flags flags = read | debug;
flags |= write;
std::cout << flags << "\n";
flags &= static_cast<Flags>(~read);
std::cout << flags << "\n";
flags ^= debug;
std::cout << flags << " " << std::boolalpha
<< ((flags & (read | write)) != 0) << " "
<< ((flags & (read | write)) == (read | write)) << "\n";
}출력은 7, 6, 2 true false입니다. 마지막 flags=2에는 WRITE만 켜졌으므로 둘 중 하나는 있지만 둘 다 있지는 않습니다. 플래그 비트가 있다는 사실을 실제 사용자의 권한 검증까지 끝냈다는 뜻으로 확대하지 않습니다.
독립 확인
마지막 토글 직후 flags ^= debug;를 한 번 더 수행하면 마지막 줄은 어떻게 바뀔까요? flags=write이고 mask=write인 단일 비트 검사에서 하나라도/모두의 결과는 각각 무엇인가요?
토글 재실행과 단일 비트 검사 풀이
두 번째 DEBUG 토글로 0010→0110, 즉 6이 됩니다. READ는 여전히 없으므로 6 true false입니다. 단일 WRITE 마스크에서는 같은 한 비트를 묻기 때문에 두 검사가 모두 참입니다. 여러 비트 마스크에서도 항상 같다는 결론은 나오지 않습니다.
여러 스레드가 같은 플래그 값을 동시에 바꾸는 경우에는 비트 식만으로 원자성이 생기지 않습니다. 해당 공유 상태에는 원자 연산이나 락 같은 별도의 동기화 계약이 필요합니다.
XOR의 가역성과 보안 경계
(x ^ key ^ key) == x이므로 같은 키를 두 번 XOR하면 원래 값으로 돌아옵니다.
#include <cstdint>
std::uint8_t original = 0x58;
std::uint8_t key = 0x0f;
std::uint8_t transformed = static_cast<std::uint8_t>(original ^ key);
std::uint8_t restored = static_cast<std::uint8_t>(transformed ^ key);이 성질만으로 암호화가 되지는 않습니다. 고정 키 XOR은 패턴과 키를 쉽게 노출할 수 있으므로, 위 코드는 가역 변환을 설명하는 예제일 뿐 보안 기능으로 사용하면 안 됩니다.
우선순위와 괄호
관련 연산자의 우선순위는 높은 쪽부터 대략 다음과 같습니다.
- 단항
~,! - 산술 연산자
- 시프트
<<,>> - 관계·동등 연산자
- 비트 AND
& - 비트 XOR
^ - 비트 OR
| - 논리 AND
&&, 논리 OR|| - 대입과 복합 대입
특히 flags & READ != 0은 원하는 검사로 묶이지 않습니다. 동등 연산자가 비트 AND보다 우선하므로 다음처럼 괄호로 마스크 계산을 먼저 드러내세요.
if ((flags & READ) != 0) {
// READ 비트가 켜져 있음
}우선순위는 구문이 묶이는 방법을 정하지만, 일반적인 피연산자 평가 순서를 모두 정하는 규칙은 아닙니다. 비트 연산 코드에서는 타입, 너비, 마스크 의미와 괄호를 함께 검토하세요.