C# 비트 연산자와 시프트 연산자

C# 비트 연산자와 시프트 연산자 (C# 참조)

비트 연산자와 시프트 연산자에는 단항 비트 보수 연산자, 이진 왼쪽·오른쪽 시프트 연산자, 부호 없는 오른쪽 시프트 연산자, 그리고 이진 논리 AND·OR·배타적 OR 연산자가 있어요. 이 연산자들의 피연산자는 정수 숫자 형식 또는 char 형식이에요.

이 연산자들은 int, uint, long, ulong, nint, nuint 형식과 함께 사용할 수 있어요. 두 피연산자가 모두 다른 정수 형식(sbyte, byte, short, ushort, char)이면 그 값들이 int 형식으로 변환되는데, 이 int가 연산의 결과 형식이 되기도 해요. 피연산자들이 서로 다른 정수 형식이면 가장 가까운 포함 정수 형식으로 변환돼요. 자세한 내용은 C# 언어 사양숫자 승격 절을 참고해요. 복합 연산자(>>= 같은)는 자신의 인자를 int로 변환하거나 결과 형식을 int로 만들지 않아요.

&, |, ^ 연산자는 bool 형식의 피연산자와도 동작해요. 자세한 내용은 부울 논리 연산자를 참고해요.

비트 및 시프트 연산은 절대 오버플로를 일으키지 않으며, checked와 unchecked 문맥에서 같은 결과를 내요.

비트 보수 연산자 ~

~ 연산자는 피연산자의 각 비트를 뒤집어 비트 보수를 만들어요:

uint a = 0b_0000_1111_0000_1111_0000_1111_0000_1100;
uint b = ~a;
Console.WriteLine(Convert.ToString(b, toBase: 2));
// Output:
// 11110000111100001111000011110011

~ 기호는 종료자(finalizer)를 선언할 때도 사용해요. 자세한 내용은 종료자를 참고해요.

왼쪽 시프트 연산자 <<

<< 연산자는 왼쪽 피연산자를 오른쪽 피연산자가 지정한 비트 수만큼 왼쪽으로 밀어요. 오른쪽 피연산자가 시프트 횟수를 어떻게 정하는지 자세한 내용은 시프트 연산자의 시프트 횟수 절을 참고해요.

왼쪽 시프트는 결과 형식의 범위를 벗어나는 상위 비트들을 버리고, 비워진 하위 비트 자리에는 0을 채워요. 다음 예제를 봐요:

uint x = 0b_1100_1001_0000_0000_0000_0000_0001_0001;
Console.WriteLine($"Before: {Convert.ToString(x, toBase: 2)}");

uint y = x << 4;
Console.WriteLine($"After:  {Convert.ToString(y, toBase: 2)}");
// Output:
// Before: 11001001000000000000000000010001
// After:  10010000000000000000000100010000

시프트 연산자는 int, uint, long, ulong 형식에만 정의되어 있어서 연산 결과는 항상 최소 32비트를 포함해요. 왼쪽 피연산자가 다른 정수 형식(sbyte, byte, short, ushort, char)이면 연산이 그 값을 int 형식으로 변환해요. 다음 예제가 그 모습을 보여줘요:

byte a = 0b_1111_0001;

var b = a << 8;
Console.WriteLine(b.GetType());
Console.WriteLine($"Shifted byte: {Convert.ToString(b, toBase: 2)}");
// Output:
// System.Int32
// Shifted byte: 1111000100000000

오른쪽 시프트 연산자 >>

>> 연산자는 왼쪽 피연산자를 오른쪽 피연산자가 정의한 비트 수만큼 오른쪽으로 밀어요. 오른쪽 피연산자가 시프트 횟수를 정하는 방식은 시프트 연산자의 시프트 횟수 절을 참고해요.

오른쪽 시프트는 하위 비트들을 버려요. 다음 예제를 봐요:

uint x = 0b_1001;
Console.WriteLine($"Before: {Convert.ToString(x, toBase: 2), 4}");

uint y = x >> 2;
Console.WriteLine($"After:  {Convert.ToString(y, toBase: 2).PadLeft(4, '0'), 4}");
// Output:
// Before: 1001
// After:  0010

상위 빈 비트 자리는 왼쪽 피연산자의 형식에 따라 다음과 같이 채워져요:

  • 왼쪽 피연산자가 int 또는 long이면, 오른쪽 시프트 연산자는 산술 시프트를 수행해요. 즉 왼쪽 피연산자의 최상위 비트(부호 비트) 값이 상위 빈 비트 자리로 전파돼요. 다시 말해 왼쪽 피연산자가 음수가 아니면 상위 빈 자리를 0으로, 음수면 1로 채워요.

    int a = int.MinValue;
    Console.WriteLine($"Before: {Convert.ToString(a, toBase: 2)}");
    
    int b = a >> 3;
    Console.WriteLine($"After:  {Convert.ToString(b, toBase: 2)}");
    // Output:
    // Before: 10000000000000000000000000000000
    // After:  11110000000000000000000000000000
    
  • 왼쪽 피연산자가 uint 또는 ulong이면, 오른쪽 시프트 연산자는 논리 시프트를 수행해요. 즉 상위 빈 비트 자리를 항상 0으로 채워요.

    uint c = 0b_1000_0000_0000_0000_0000_0000_0000_0000;
    Console.WriteLine($"Before: {Convert.ToString(c, toBase: 2), 32}");
    
    uint d = c >> 3;
    Console.WriteLine($"After:  {Convert.ToString(d, toBase: 2).PadLeft(32, '0'), 32}");
    // Output:
    // Before: 10000000000000000000000000000000
    // After:  00010000000000000000000000000000
    

[!NOTE] 부호 있는 정수 형식의 피연산자에 논리 시프트를 하려면 부호 없는 오른쪽 시프트 연산자를 사용해요. 논리 시프트가 더 선호돼요. 왼쪽 피연산자를 부호 없는 형식으로 캐스팅한 다음 시프트 연산 결과를 다시 부호 있는 형식으로 캐스팅하는 방식은 피하는 게 좋아요.

부호 없는 오른쪽 시프트 연산자 >>>

>>> 연산자는 왼쪽 피연산자를 오른쪽 피연산자가 정의한 비트 수만큼 오른쪽으로 밀어요. 오른쪽 피연산자가 시프트 횟수를 정하는 방식은 시프트 연산자의 시프트 횟수 절을 참고해요.

>>> 연산자는 항상 논리 시프트를 수행해요. 즉 왼쪽 피연산자의 형식과 무관하게 상위 빈 비트 자리를 항상 0으로 채워요. 반면 >> 연산자는 왼쪽 피연산자가 부호 있는 형식이면 산술 시프트(최상위 비트의 값이 상위 빈 비트 자리로 전파)를 수행해요. 다음 예제는 음수 왼쪽 피연산자에 대해 >>>>>가 어떻게 다른지 보여줘요:

int x = -8;
Console.WriteLine($"Before:    {x,11}, hex: {x,8:x}, binary: {Convert.ToString(x, toBase: 2), 32}");

int y = x >> 2;
Console.WriteLine($"After  >>: {y,11}, hex: {y,8:x}, binary: {Convert.ToString(y, toBase: 2), 32}");

int z = x >>> 2;
Console.WriteLine($"After >>>: {z,11}, hex: {z,8:x}, binary: {Convert.ToString(z, toBase: 2).PadLeft(32, '0'), 32}");
// Output:
// Before:             -8, hex: fffffff8, binary: 11111111111111111111111111111000
// After  >>:          -2, hex: fffffffe, binary: 11111111111111111111111111111110
// After >>>:  1073741822, hex: 3ffffffe, binary: 00111111111111111111111111111110

논리 AND 연산자 &

& 연산자는 정수 피연산자의 비트를 대상으로 논리 AND를 계산해요:

uint a = 0b_1111_1000;
uint b = 0b_1001_1101;
uint c = a & b;
Console.WriteLine(Convert.ToString(c, toBase: 2));
// Output:
// 10011000

bool 피연산자에 대해서는 & 연산자가 논리 AND를 계산해요. 단항 & 연산자는 주소 연산자예요.

논리 배타적 OR 연산자 ^

^ 연산자는 정수 피연산자의 비트를 대상으로 논리 배타적 OR, 즉 비트 논리 XOR을 계산해요:

uint a = 0b_1111_1000;
uint b = 0b_0001_1100;
uint c = a ^ b;
Console.WriteLine(Convert.ToString(c, toBase: 2));
// Output:
// 11100100

bool 피연산자에 대해서는 ^ 연산자가 논리 배타적 OR를 계산해요.

논리 OR 연산자 |

| 연산자는 정수 피연산자의 비트를 대상으로 논리 OR을 계산해요:

uint a = 0b_1010_0000;
uint b = 0b_1001_0001;
uint c = a | b;
Console.WriteLine(Convert.ToString(c, toBase: 2));
// Output:
// 10110001

bool 피연산자에 대해서는 | 연산자가 논리 OR을 계산해요.

복합 할당

이진 연산자 op에 대해, 다음 형태의 복합 할당 식

x op= y

은 다음 식과 같아요. 단, x는 한 번만 평가된다는 점만 달라요.

x = x op y

다음 예제는 비트 및 시프트 연산자와 복합 할당을 함께 쓰는 모습을 보여줘요:

uint INITIAL_VALUE = 0b_1111_1000;

uint a = INITIAL_VALUE;
a &= 0b_1001_1101; 
Display(a);  // output: 10011000

a = INITIAL_VALUE;
a |= 0b_0011_0001; 
Display(a);  // output: 11111001

a = INITIAL_VALUE;
a ^= 0b_1000_0000;
Display(a);  // output: 01111000

a = INITIAL_VALUE;
a <<= 2;
Display(a);  // output: 1111100000

a = INITIAL_VALUE;
a >>= 4;
Display(a);  // output: 00001111

a = INITIAL_VALUE;
a >>>= 4;
Display(a);  // output: 00001111

void Display(uint x) => Console.WriteLine($"{Convert.ToString(x, toBase: 2).PadLeft(8, '0'), 8}");

숫자 승격 때문에 op 연산의 결과가 x의 형식 T로 암시적으로 변환되지 못할 수도 있어요. 그런 경우에 op가 미리 정의된 연산자이고 연산 결과가 x의 형식 T로 명시적으로 변환될 수 있다면, x op= y 형태의 복합 할당 식은 x = (T)(x op y)와 같아요. 여기서도 x는 한 번만 평가돼요. 다음 예제가 그 동작을 보여줘요:

byte x = 0b_1111_0001;

int b = x << 8;
Console.WriteLine($"{Convert.ToString(b, toBase: 2)}");  // output: 1111000100000000

x <<= 8;
Console.WriteLine(x);  // output: 0

연산자 우선순위

다음 목록은 비트 및 시프트 연산자를 우선순위가 높은 순서에서 낮은 순서로 그룹으로 묶은 거예요:

기본 연산자 우선순위와 다른 순서로 평가하려면 괄호 ()를 사용해요:

uint a = 0b_1101;
uint b = 0b_1001;
uint c = 0b_1010;

uint d1 = a | b & c;
Display(d1);  // output: 1101

uint d2 = (a | b) & c;
Display(d2);  // output: 1000

void Display(uint x) => Console.WriteLine($"{Convert.ToString(x, toBase: 2), 4}");

우선순위 수준별로 정렬한 C# 연산자 전체 목록은 C# 연산자 문서의 연산자 우선순위 절을 참고해요.

시프트 연산자의 시프트 횟수

x << count, x >> count, x >>> count 식에서 실제 시프트 횟수는 x의 형식에 따라 다음과 같이 달라져요:

  • x의 형식이 int 또는 uint면, 오른쪽 피연산자의 하위 다섯 비트가 시프트 횟수를 정해요. 즉 count & 0x1F(또는 count & 0b_1_1111)로 시프트 횟수를 계산해요.

  • x의 형식이 long 또는 ulong이면, 오른쪽 피연산자의 하위 여섯 비트가 시프트 횟수를 정해요. 즉 count & 0x3F(또는 count & 0b_11_1111)로 시프트 횟수를 계산해요.

다음 예제가 그 동작을 보여줘요:

int count1 = 0b_0000_0001;
int count2 = 0b_1110_0001;

int a = 0b_0001;
Console.WriteLine($"{a} << {count1} is {a << count1}; {a} << {count2} is {a << count2}");
// Output:
// 1 << 1 is 2; 1 << 225 is 2

int b = 0b_0100;
Console.WriteLine($"{b} >> {count1} is {b >> count1}; {b} >> {count2} is {b >> count2}");
// Output:
// 4 >> 1 is 2; 4 >> 225 is 2

int count = -31;
int c = 0b_0001;
Console.WriteLine($"{c} << {count} is {c << count}");
// Output:
// 1 << -31 is 2

[!NOTE] 위 예제에서 볼 수 있듯이, 오른쪽 피연산자의 값이 왼쪽 피연산자의 비트 수보다 커도 시프트 연산 결과가 0이 아닐 수 있어요.

열거형 논리 연산자

모든 열거형 형식은 ~, &, |, ^ 연산자를 지원해요. 같은 열거형 형식의 피연산자끼리는 그 기반이 되는 정수 형식의 해당 값에 대해 논리 연산이 수행돼요. 예를 들어 기반 형식이 U인 열거형 형식 T의 어떤 xy에 대해서도 x & y 식은 (T)((U)x & (U)y) 식과 같은 결과를 내요.

보통 비트 논리 연산자는 Flags 특성과 함께 정의한 열거형에 사용해요. 자세한 내용은 열거형 형식 문서의 열거형 형식을 비트 플래그로 사용하기 절을 참고해요.

연산자 오버로드 가능성

사용자 정의 형식은 ~, <<, >>, >>>, &, |, ^ 연산자를 오버로드할 수 있어요. 이진 연산자를 오버로드하면 해당 복합 할당 연산자도 암시적으로 함께 오버로드돼요. C# 14부터는 사용자 정의 형식이 복합 할당 연산자를 명시적으로 오버로드해서 더 효율적인 구현을 제공할 수 있어요. 보통 이진 연산의 결과를 담을 새 인스턴스를 할당하는 대신 값을 그 자리에서 갱신할 수 있기 때문에 형식이 이런 연산자들을 오버로드해요. 형식이 명시적 오버로드를 제공하지 않으면 컴파일러가 암시적 오버로드를 만들어요.

사용자 정의 형식 T<<, >>, >>> 연산자를 오버로드하면 왼쪽 피연산자의 형식은 반드시 T여야 해요. C# 10 이하에서는 오른쪽 피연산자의 형식이 int여야 해요. (C# 11부터는) 오버로드된 시프트 연산자의 오른쪽 피연산자 형식은 어떤 형식이든 될 수 있어요.

C# 언어 사양

자세한 내용은 C# 언어 사양의 다음 절들을 참고해요:

출처: Bitwise and shift operators (C# reference)

더 알아보기