/home/caml-shaving

함부로 unsigned를 쓰지 말아야 할 이유

2021-09-29

태그: dev think

수정 (2021/09/30)

글의 방향이 원래 의도와 다르게 “무조건 unsigned를 쓰지 말자”로 읽히는 것 같아서, 내용을 좀더 보강해보았다. (Special thanks to kkeun.net!)

어쩌다가 C의 타입 캐스팅, 특히 unsigned와 관련된 걸 찾아보게 되었는데… 납득이 가긴 하지만 시간이 지나서 보면 100% 헷갈릴 것이기 때문에 (…) 짧게 기록해둔다.

Integral Promotion

char, short 뿐 아니라 unsignedenum 타입은 expression에서 int 타입으로 승격(promotion)되어서 실제 연산에 쓰인다. 예를 들면, binary operation과 bit-shift operation에서 이 타입들이 연산에 쓰이면, 일단 int 형으로 캐스팅되고 나서 계산이 이루어지고 이 int 결과 값을 최종 변수에 담는다.

Integer Promotion이라고도 한다.

Zero/Sign Extension

메모리 표현이 작은 타입(e.g. char)을 더 큰 타입(e.g. int)에 담으려면, 작은 타입에 비어있는 비트를 큰 타입에 맞게 확장해야 한다. 이때 부호에 맞게 채워야 한다. 만약 작은 타입의 값이 양수라면 그냥 0으로 다 채우면 되지만(Zero Extension), 음수라면 2의 보수법을 따라야 하므로 1로 채우게 된다(Sign Extension).

예를 들어, 10비트 음수 값인 11 1111 0001 (즉 -15)를 16비트에 담으려면 비어있는 6비트만큼을 모두 1로 채워서 1111 1111 1111 0001로 확장해야지만 16비트에서 -15를 표현하는 정확한 2의 보수가 된다.


이 두 가지 행동 각각은 납득이 가긴 한다. 하지만 이걸 고려하지 않고 unsigned를 쓰다보면 의도치 않은 동작을 일으킬 수 있다.


unsigned char p = 0xff;
unsigned long long res = p << 24;
// expected: 0x0000 0000 ff00 0000
// actual  : 0xffff ffff ff00 0000

printf("Expected value: %lu, Actual value: %lu\n", 0x00000000ff000000, res);
// Expected value: 4278190080, Actual value: 18446744073692774400

p의 타입이 unsigned char이지만, bit-shift 계산을 위해 int 형으로 integral promotion 된다. 그래서 p << 240xff00 0000를 담은 32비트 정수값이 되어버린다. 여기까진 좋다.

하지만 이 최종 값을 unsigned long long 타입에 담을 때 sign extension이 발생하게 된다. 먼저 int 타입의 0xff00 0000은 MSB가 1이므로 2의 보수 표기법에 따라 이를 음수로 해석하게 되고, 모자란 32비트를 모조리 1로 채운다. 따라서 결과 값은 0xffff ffff ff00 0000이 되어 버린다. 그 이후 unsigned가 적용되므로, res의 값은 엄청나게 큰 값이 되어버린다. 값을 찍어보면 기대했던 0x0000 0000 ff00 0000보다 엄청나게 큰 값을 볼 수 있다.

현실에서는 이 값이 복잡한 경로(e.g. 입력)로 들어오기 때문에, 곧바로 확인하기가 어렵다. 거기다 이렇게 들어온 값이 직접 쓰이게 되면 경우에 따라 가끔씩 의도치 않은 동작을 할 수 있어서 더 헷갈린다.

위의 코드를 좀더 꼬아서 다음과 같은 경우를 생각해보자.

unsigned char p = 0xff;  // maybe from input
unsigned long long k = 0x00000000ff000000; // save correct value
printf("p << 24: 0x%016lx, k: 0x%016lx\n", p << 24, k);
// p << 24: 0x00000000ff000000, k: 0x00000000ff000000
// print the same representation!

if ( (p << 24) > k) {
  printf("You shall not reach here!\n");
}

디버깅을 위해 값을 하나씩 찍어보면, p << 24의 값은 0이 잘 채워진 0x0000 0000 ff00 0000이 나온다. 그러므로 (p << 24) > k의 결과는 false가 되어 브랜치 안의 printf에 도달하지 못할 거라는 예측은 언뜻 그럴듯하다. 하지만 실제로 이 코드를 컴파일해서 실행하면 “You shall not reach here!” 메시지가 뜬다. 각각을 출력할 때에는 의도한 결과가 나왔지만, (p << 24) > k를 계산하는 과정에서 sign extension이 발생하여 의도치않게 저기에 도달하고 만 것이다.


대부분의 언어/하드웨어에서는 음수의 표현으로 2의 보수법을 쓰고, 또 학교에서 가르치기도 해서, 일반적으로 signed의 동작 방식은 잘 받아들여진다. 하지만 unsigned는 추가적인 해석이 필요하기 때문에, 왜 이렇게 동작하는지 잘 알아두지 않으면 헷갈릴 수 밖에 없다. unsigned는 보통 절대 음수가 될 수 없는 값을 표현하거나, 혹은 비트 연산을 할 때 많이 사용하기 때문에(왜 그럴까?), C/C++ 또는 Rust와 같은 로우 레벨의 시스템 프로그래밍 언어에서 필요한 도구인 것은 분명하다. 하지만, 추가적인 복잡함을 고려하지 않고 아무 생각없이 unsigned를 쓰다가는, 이처럼 의도치 않은 동작을 마주할 가능성이 높다.

개인적으로는 “정말 unsigned 타입이 필요할까?”에 대한 확신이 없다면, 그냥 unsigned를 쓰지 않는 것이 좋아보인다. signed의 동작은 우리 프로그래머에게 친숙하고, 여기서 설명한 암묵적인 상황에서는 signed로 취급되는 경우가 많아서, 이상하게 동작하더라도 우리에게 익숙한 방향으로 동작할테니 말이다.

좀더 솔직하게 말하자면, 가능하면 이런 복잡하고 헷갈리는 코너 케이스를 애초부터 고려하지 않아도 되는 언어를 쓰는 것이 좋다. 예를 들면 OCaml 이라던가, OCaml 이라던가 … 😅