std::bitset을 활용한 비트 조작(Bit manipulation)

• 불리언 타입은 오직 참(true, 1)이나 거짓(false, 0), 이 두 가지 상태만 가지잖아요? 이 상태를 저장하는 데는 단 1비트(bit)면 충분하거든요.

• 그런데 변수가 최소 1바이트(8비트) 크기를 가져야 한다면, 불리언 값 하나를 저장하기 위해 1비트만 쓰고 나머지 7비트는 사용하지 않고 버려둔다는 뜻이 됩니다.


• 대부분의 상황에서는 크게 신경 쓰지 않아도 괜찮아요.

• 버려지는 7비트를 아끼는 것보다 코드를 이해하고 유지 보수하기 쉽게 만드는 게 더 중요하니까요.

• 하지만 저장 공간이 매우 중요한 일부 상황에서는, 8개의 개별 불리언 값을 하나의 바이트 안에 꽉꽉 눌러 담아 저장 효율을 높이는 것이 아주 유용할 수 있어요.


• 이렇게 하려면 객체를 비트 수준에서 다룰 수 있어야 하는데요.

• 다행히 C++는 우리에게 딱 맞는 도구들을 제공해 준답니다!

• 이렇게 객체 내의 개별 비트를 수정하는 것을 비트 조작(Bit manipulation)이라고 불러요.


• 비트 조작은 그래픽, 암호화, 데이터 압축, 최적화 같은 특정 프로그래밍 분야에서 아주 많이 쓰이지만, 일반적인 프로그래밍에서는 자주 쓰이지는 않아요.

• 그래서 이 챕터 전체는 선택 사항이랍니다. 가볍게 훑어보시거나 건너뛴 다음, 나중에 필요할 때 다시 돌아와서 읽으셔도 전혀 문제없어요!


비트 플래그(Bit flags)

• 지금까지 우리는 변수 하나에 하나의 값만 저장해 왔어요.

• int foo { 5 };

• 하지만 객체가 하나의 값만 가진다고 생각하는 대신, 객체 안의 각 비트 하나하나를 독립적인 불리언 값으로 취급할 수도 있어요.

• 이렇게 개별 비트들이 불리언 값처럼 사용될 때, 이 비트들을 비트 플래그(Bit flags)라고 부릅니다.


• 비트 플래그들의 집합을 정의하기 위해, 우리는 보통 필요한 플래그 개수에 맞는 적절한 크기의 부호 없는 정수나 std::bitset을 사용한답니다.


비트 번호 매기기와 비트 위치(Bit positions)

• 일련의 비트들이 있을 때, 우리는 보통 오른쪽에서 왼쪽으로 번호를 매깁니다.

• 이때 1이 아니라 0부터 시작한다는 점을 꼭 기억해 주세요!

• 각 숫자는 비트 위치(Bit position)를 나타내요.


• 76543210 비트 위치 (Bit position)

• 00000101 비트 시퀀스 (Bit sequence)


• 위의 0000 0101이라는 비트 시퀀스를 보면, 위치 0과 위치 2에 있는 비트가 1의 값을 가지고 있고, 나머지 비트들은 모두 0의 값을 가지고 있음을 알 수 있어요.


std::bitset으로 비트 조작하기

• std::bitset은 비트 조작에 매우 유용한 4가지 핵심 멤버 함수를 제공해요.


• test(): 특정 비트가 0인지 1인지 알아볼 때 사용해요.

• set(): 특정 비트를 켜고 싶을(1로 만들고 싶을) 때 사용해요. (이미 켜져 있다면 아무 일도 일어나지 않아요.)

• reset(): 특정 비트를 끄고 싶을(0으로 만들고 싶을) 때 사용해요. (이미 꺼져 있다면 아무 일도 일어나지 않아요.)

• flip(): 비트 값을 0에서 1로, 또는 1에서 0으로 반대로 뒤집고 싶을 때 사용해요.


• 이 함수들은 모두 우리가 조작하고 싶은 비트의 위치(position) 하나를 유일한 인수로 받습니다.


#include <bitset>

#include <iostream>


int main()

{

    std::bitset<8> bits{ 0b0000'0101 }; // 8비트가 필요하고, 초기 비트 패턴은 0000 0101로 시작해요.

    bits.set(3);   // 위치 3의 비트를 1로 설정해요 (이제 0000 1101이 됩니다)

    bits.flip(4);  // 위치 4의 비트를 뒤집어요 (이제 0001 1101이 됩니다)

    bits.reset(4); // 위치 4의 비트를 다시 0으로 꺼요 (이제 0000 1101이 됩니다)


    std::cout << "모든 비트: " << bits << '\n';

    std::cout << "비트 3의 값: " << bits.test(3) << '\n';

    std::cout << "비트 4의 값: " << bits.test(4) << '\n';


    return 0;

}


• 이 코드를 실행하면 이렇게 출력된답니다.


모든 비트: 00001101

비트 3의 값: 1

비트 4의 값: 0


• 비트들에게 의미 있는 이름을 붙여주면 코드를 훨씬 더 읽기 쉽게 만들 수 있어요.


#include <bitset>

#include <iostream>


int main()

{

    [[maybe_unused]] constexpr int  isHungry   { 0 };

    [[maybe_unused]] constexpr int  isSad      { 1 };

    [[maybe_unused]] constexpr int  isMad      { 2 };

    [[maybe_unused]] constexpr int  isHappy    { 3 };

    [[maybe_unused]] constexpr int  isLaughing { 4 };

    [[maybe_unused]] constexpr int  isAsleep   { 5 };

    [[maybe_unused]] constexpr int  isDead     { 6 };

    [[maybe_unused]] constexpr int  isCrying   { 7 };


    std::bitset<8> me{ 0b0000'0101 }; // 8비트가 필요하고, 초기 비트 패턴은 0000 0101이에요.

    me.set(isHappy);      // 위치 3(isHappy)의 비트를 1로 설정해요 (이제 0000 1101이 됩니다)

    me.flip(isLaughing);  // 위치 4(isLaughing)의 비트를 뒤집어요 (이제 0001 1101이 됩니다)

    me.reset(isLaughing); // 위치 4(isLaughing)의 비트를 다시 0으로 꺼요 (이제 0000 1101이 됩니다)


    std::cout << "모든 비트: " << me << '\n';

    std::cout << "나는 행복한가?: " << me.test(isHappy) << '\n';

    std::cout << "나는 웃고 있는가?: " << me.test(isLaughing) << '\n';


    return 0;

}


한 번에 여러 비트를 가져오거나 설정하고 싶다면요?

• 안타깝게도 std::bitset으로는 한 번에 여러 비트를 조작하는 게 조금 번거로워요.

• 이런 작업을 하고 싶거나 std::bitset 대신 부호 없는 정수를 직접 비트 플래그로 사용하고 싶다면, 조금 더 전통적인 방식을 사용해야 합니다.


std::bitset의 크기에 대한 비밀

• std::bitset은 메모리를 아끼기보다는 속도를 최적화하는 데 초점이 맞춰져 있답니다.

• std::bitset의 크기는 비트들을 담는 데 필요한 바이트 수를 sizeof(size_t) 단위로 올림하여 결정돼요.


• size_t는 32비트 컴퓨터에서는 4바이트, 64비트 컴퓨터에서는 8바이트예요.

• 따라서 std::bitset<8>은 기술적으로는 8개의 비트를 저장하기 위해 단 1바이트만 있으면 되지만, 실제로는 시스템에 따라 4바이트나 8바이트의 메모리를 차지하게 됩니다.

• 즉, std::bitset은 메모리 절약이 목적일 때보다는 '편리함'이 필요할 때 사용하는 것이 가장 좋습니다.


std::bitset에 질문 던지기 (상태 확인하기)

• 앞서 배운 4가지 외에도 자주 쓰이는 유용한 멤버 함수들이 몇 가지 더 있어요.


• size(): 비트셋 안에 총 몇 개의 비트가 있는지 개수를 알려줘요.

• count(): 참(true, 1)으로 설정된 비트가 몇 개인지 세어줘요.

• all(): 모든 비트가 참인지 확인해서 불리언(Boolean) 값으로 알려줘요.

• any(): 참인 비트가 하나라도 있는지 확인해서 불리언(Boolean) 값으로 알려줘요.

• none(): 참인 비트가 단 하나도 없는지(모두 거짓인지) 확인해서 불리언(Boolean) 값으로 알려줘요.


비트 단위 연산자란?

• C++은 6가지의 비트 조작 연산자를 제공하는데, 이를 흔히 비트 단위 연산자(bitwise operators)라고 불러요.

• 비트 연산자는 부호 없는 정수 또는 std::bitset과 함께 사용하세요.


연산자기호형식연산 결과 설명
왼쪽 시프트 (Left shift)<<x << nx의 비트들을 왼쪽으로 n칸 이동. 새로 생기는 빈칸은 0으로 채움.
오른쪽 시프트 (Right shift)>>x >> nx의 비트들을 오른쪽으로 n칸 이동. 새로 생기는 빈칸은 0으로 채움.
비트 부정 (Bitwise NOT)~~xx의 모든 비트를 뒤집음 (0→1, 1→0).
비트 AND (Bitwise AND)&x & yx와 y의 대응 비트가 둘 다 1일 때만 1.
비트 OR (Bitwise OR)|x | yx와 y의 대응 비트 중 하나라도 1이면 1.
비트 XOR (Bitwise XOR)^x ^ yx와 y의 대응 비트가 서로 다를 때 1.


비트 왼쪽 시프트(<<)와 오른쪽 시프트(>>) 연산자

• 비트 왼쪽 시프트(<<) 연산자는 비트들을 왼쪽으로 이동시켜요.

• 예를 들어 x << 2라고 쓰면, "x의 비트들을 왼쪽으로 2칸 이동시킨 값을 만들어라"라는 뜻이죠.

• 이때 원래 값인 왼쪽 피연산자는 변경되지 않으며, 오른쪽에서 새로 들어오는 비트들은 0으로 채워집니다.


• 0011이라는 비트를 왼쪽으로 이동시키는 예시를 볼까요?

• 0011 << 1 은 0110

• 0011 << 2 은 1100

• 0011 << 3 은 1000


• 세 번째 경우를 잘 보세요.

• 끝에 있던 1이 밖으로 밀려났죠? 비트 배열의 끝을 넘어간 비트들은 영원히 사라집니다.

• 비트 오른쪽 시프트(>>) 연산자도 비슷하게 작동합니다. 방향이 오른쪽이라는 점만 달라요.


비트 부정 (Bitwise NOT)

• 비트 부정 연산자는 개념적으로 아주 간단해요.

• 그냥 모든 비트를 0에서 1로, 혹은 그 반대로 뒤집는 거예요.


• ~0011 은 1100

• ~0000 0100 은 1111 1011


비트 OR (Bitwise OR)

• 비트 OR은 논리 OR 연산자와 비슷하게 작동해요.

• 논리 OR는 둘 중 하나라도 참(true)이면 결과가 참이 되었죠?

• 비트 OR는 전체가 아니라 각 비트 자리마다 OR 연산을 수행해요.


• 0b0101 | 0b0110 이라는 식을 예로 들어볼게요.


0 1 0 1 OR

0 1 1 0

-------

0 1 1 1


• 결과는 이진수 0111이 됩니다.


std::cout << (std::bitset<4>{ 0b0101 } | std::bitset<4>{ 0b0110 }) << '\n';

출력 결과: 0111


• 여러 개를 한꺼번에 연산(0b0111 | 0b0011 | 0b0001)할 때도 마찬가지예요.

• 각 열에 1이 하나라도 있으면 그 열의 결과는 1이 됩니다.


0 1 1 1 OR

0 0 1 1 OR

0 0 0 1

--------

0 1 1 1


비트 AND (Bitwise AND)

• 비트 AND도 비슷하지만, OR 대신 AND 논리를 사용해요.

• 즉, 두 비트가 모두 1일 때만 결과가 1이 되고, 아니면 0이 됩니다.


• 0b0101 & 0b0110을 계산해 볼까요?

0 1 0 1 AND 0 1 1 0 -------- 0 1 0 0

• 두 번째 비트만 둘 다 1이라서 결과가 1이 되었네요.

• 여러 개를 연산(0b0001 & 0b0011 & 0b0111)할 때는, 해당 열의 비트가 모두 1이어야만 결과가 1이 됩니다.


0 0 0 1 AND

0 0 1 1 AND

0 1 1 1

--------

0 0 0 1


비트 XOR (Bitwise XOR)

• 마지막은 비트 XOR또는 배타적 논리합(exclusive or)이라고 부르는 연산자예요.

• 이 친구는 두 비트가 서로 다를 때 1이 되고, 같으면 0이 됩니다.

• 쉽게 말해 "너랑 나랑 다르면 참!"이라는 거죠.


• 0b0110 ^ 0b0011을 계산해 봅시다.


0 1 1 0 XOR

0 0 1 1

-------

0 1 0 1


• 여러 개를 XOR 연산할 때는 어떨까요?

• 각 열에서 1의 개수가 홀수 개이면 결과가 1이 되고, 짝수 개이면 0이 됩니다.


0 0 0 1 XOR

0 0 1 1 XOR

0 1 1 1

--------

0 1 0 1


비트 대입 연산자 (Bitwise assignment operators)

• 산술 연산자(+=, -=)처럼 비트 연산자도 대입과 동시에 연산을 수행하는 단축형이 있어요.

• 이 연산자들은 왼쪽 변수의 값을 변경합니다.

• 예를 들어, x = x >> 1; 대신 x >>= 1;이라고 간단히 쓸 수 있죠.


연산자기호형식연산 및 대입 설명
왼쪽 시프트 대입<<=x <<= nx를 n만큼 왼쪽 시프트하고 그 결과를 x에 저장해요.
오른쪽 시프트 대입>>=x >>= nx를 n만큼 오른쪽 시프트하고 그 결과를 x에 저장해요.
비트 AND 대입&=x &= yx와 y를 비트 AND 연산하고 결과를 x에 저장해요.
비트 OR 대입|=x |= yx와 y를 비트 OR 연산하고 결과를 x에 저장해요.
비트 XOR 대입^=x ^= y

x와 y를 비트 XOR 연산하고 결과를 x에 저장해요


비트 연산자는 작은 정수 타입을 승격(promote)시킵니다

• int보다 작은 정수 타입(예: char, short)을 비트 연산자에 사용하면, 이 값들은 자동으로 int나 unsigned int로 승격(변환)되어 계산됩니다.

• 그리고 결과값도 int나 unsigned int로 반환되죠.


• 가능하다면 int보다 작은 정수 타입에는 비트 시프트 연산을 피하는 것이 좋습니다.

• 만약 꼭 해야 한다면, static_cast를 통해 결과를 원하는 타입으로 다시 변환해 주세요.


비트 마스크 (Bit masks)

• 우리가 개별 비트를 조작(예: 켜거나 끄는 것)하려면, 수많은 비트 중에서 '내가 건드리고 싶은 특정 비트'가 무엇인지 컴퓨터에게 알려줘야 해요.

• 하지만 아쉽게도 비트 단위 연산자들은 "3번째 비트를 바꿔줘" 같은 위치 기반 명령을 직접 알아듣지 못합니다.

• 대신 그들은 비트 마스크(Bit mask)라는 도구를 사용해요.


• 비트 마스크(Bit mask)란, 뒤이어 올 연산에 의해 수정될 특정 비트들을 선택하기 위해 미리 정의해 둔 비트들의 집합을 말합니다.


• 이해를 돕기 위해 페인트칠을 예로 들어볼게요.

• 창문 틀에 페인트를 칠하려고 하는데, 유리에 페인트가 묻으면 안 되겠죠?


• 그래서 우리는 유리에 마스킹 테이프를 붙입니다.

• 그러면 페인트를 칠해도 테이프가 붙은 곳(유리)은 보호되고, 테이프가 없는 곳(창문 틀)만 색이 칠해지죠.


• 비트 마스크도 똑같은 역할을 해요.

• 우리가 원하지 않는 비트는 건드리지 않도록 막아주고, 우리가 원하는 비트에만 접근할 수 있게 해 준답니다.


C++14에서 비트 마스크 정의하기

• 가장 기본적인 비트 마스크는 각 비트 위치(0번, 1번...)마다 하나씩 마스크를 만들어 두는 거예요.

• 상관없는 비트는 0으로, 조작하고 싶은 비트는 1로 표시합니다.


• 보통은 나중에 다시 쓰기 편하도록 의미 있는 이름을 붙여서 상수로 정의해 둡니다.

• C++14부터는 이진수 리터럴(Binary literals)을 지원하기 때문에 아주 직관적으로 만들 수 있어요.


#include <cstdint>


constexpr std::uint8_t mask0{ 0b0000'0001 }; // 0번 비트를 나타냅니다

constexpr std::uint8_t mask1{ 0b0000'0010 }; // 1번 비트를 나타냅니다

constexpr std::uint8_t mask2{ 0b0000'0100 }; // 2번 비트를 나타냅니다

constexpr std::uint8_t mask3{ 0b0000'1000 }; // 3번 비트를 나타냅니다

constexpr std::uint8_t mask4{ 0b0001'0000 }; // 4번 비트를 나타냅니다

constexpr std::uint8_t mask5{ 0b0010'0000 }; // 5번 비트를 나타냅니다

constexpr std::uint8_t mask6{ 0b0100'0000 }; // 6번 비트를 나타냅니다

constexpr std::uint8_t mask7{ 0b1000'0000 }; // 7번 비트를 나타냅니다


비트 상태 확인하기 (Testing a bit)

• 특정 비트가 켜져 있는지(1인지) 꺼져 있는지(0인지) 알고 싶을 땐, 비트 단위 AND 연산자(&)를 사용합니다.


#include <cstdint>

#include <iostream>


int main()

{

    // ... 마스크 정의 생략 (위와 동일) ...

    [[maybe_unused]] constexpr std::uint8_t mask0{ 0b0000'0001 }; 

    [[maybe_unused]] constexpr std::uint8_t mask1{ 0b0000'0010 }; 


    std::uint8_t flags{ 0b0000'0101 }; // 8개의 플래그 중 0번과 2번이 켜져 있네요.


    // 0번 비트 확인

    std::cout << "bit 0 is " << (static_cast<bool>(flags & mask0) ? "on\n" : "off\n");

    // 1번 비트 확인

    std::cout << "bit 1 is " << (static_cast<bool>(flags & mask1) ? "on\n" : "off\n");


    return 0;

}


• 출력 결과:

• bit 0 is on

• bit 1 is off


• 원리 설명: flags & mask0를 하면 두 비트가 모두 1인 자리만 1이 남습니다.


0000 0101 (flags)

0000 0001 (mask0)

--------- (AND 연산)


0000 0001 (결과: 0이 아님 -> True)


반면 flags & mask1은:


0000 0101 (flags)

0000 0010 (mask1)

--------- (AND 연산)


0000 0000 (결과: 0임 -> False)


비트 켜기 (Setting a bit)

특정 비트를 켜고(1로 설정) 싶을 때는 비트 단위 OR 대입 연산자(|=)를 사용합니다.


// ... 마스크 정의 생략 ...

std::uint8_t flags{ 0b0000'0101 };


std::cout << "bit 1 is " << (static_cast<bool>(flags & mask1) ? "on\n" : "off\n");


flags |= mask1; // 1번 비트를 켭니다!


std::cout << "bit 1 is " << (static_cast<bool>(flags & mask1) ? "on\n" : "off\n");


• OR 연산(|)을 사용하면 여러 비트를 한 번에 켤 수도 있습니다.


flags |= (mask4 | mask5); // 4번과 5번 비트를 동시에 켭니다.


비트 끄기 / 초기화 (Resetting a bit)

• 특정 비트를 끄고(0으로 설정) 싶을 때는 비트 단위 AND(&)와 비트 단위 NOT(~)을 함께 사용해야 해요.

• 조금 복잡해 보일 수 있지만 원리를 알면 간단합니다.


// ... 마스크 정의 생략 ...

std::uint8_t flags{ 0b0000'0101 };


std::cout << "bit 2 is " << (static_cast<bool>(flags & mask2) ? "on\n" : "off\n");


flags &= ~mask2; // 2번 비트를 끕니다!


std::cout << "bit 2 is " << (static_cast<bool>(flags & mask2) ? "on\n" : "off\n");


• 원리 설명:


• 1. mask2는 0000 0100입니다.

• 2. ~mask2 (NOT 연산)를 하면 1111 1011이 됩니다. (2번 비트만 0이고 나머지는 1)

• 3. 이제 flags와 1111 1011을 AND 연산합니다.

• 4. 0과 만나는 2번 비트는 무조건 0이 되고, 1과 만나는 나머지 비트는 원래 값을 유지합니다.


 주고사항: ~mask2를 할 때 컴파일러가 경고를 낼 수 있습니다.

 mask2는 작은 자료형인데 ~ 연산을 하면서 int로 승격되기 때문이에요. 이럴 때는 다시 원래 타입으로 캐스팅해주면 됩니다.


flags &= static_cast<std::uint8_t>(~mask2);


비트 뒤집기 (Flipping a bit)

 비트의 상태를 반대로(0은 1로, 1은 0으로) 바꾸고 싶다면 비트 단위 XOR 연산자(^)를 사용합니다.


// ... 마스크 정의 생략 ...

std::uint8_t flags{ 0b0000'0101 };


std::cout << "bit 2 is " << (static_cast<bool>(flags & mask2) ? "on\n" : "off\n");

flags ^= mask2; // 2번 비트를 뒤집습니다.

std::cout << "bit 2 is " << (static_cast<bool>(flags & mask2) ? "on\n" : "off\n");

flags ^= mask2; // 다시 뒤집습니다.

std::cout << "bit 2 is " << (static_cast<bool>(flags & mask2) ? "on\n" : "off\n");


비트 마스크와 std::bitset

 C++ 표준 라이브러리의 std::bitset을 사용해도 비트 조작이 가능합니다.

 test(), set(), reset(), flip() 같은 함수를 제공해서 편리하죠.

 하지만 여러 비트를 한꺼번에 조작하려면 여전히 비트 연산자를 사용하는 것이 좋습니다.