모던 C (옌스 구스테트)를 읽으며 정리하는 학습 노트
들어가며
5장 값과 데이터 파트를 읽으며 좀처럼 내 환경에서는 찾아볼 수 없던 !! 문법을 발견했다. 이건 좀 더 디테일하게 branchless 패턴이라고 하던데 좀 더 깊은 탐구가 필요하여 다른 글에서 잇도록 하겠습니다.
5장은 추상 상태 기계를 이해하기 위한 내용을 시작합니다. 현대의 컴파일러란 놀라울 정도의 최적화로 아름다운 성능을 가진 오브젝트를 만들어내곤 합니다. abstract를 추상으로 번역할 수 밖에 없는 상황이 안타까울 따름입니다. 유독 이번에 표현이 얼마나 모호한지 절실히 느꼈습니다.
추상상태란, C 표준이 프로그램의 실행을 정의하기 위해 상정하는 가상의 실행 모델입니다. 정식 용어로는 추상 기계(abstract machine) 라고 합니다. 실제 하드웨어가 아니라 표준이 규정하는 "이상적인 컴퓨터"이며, 컴파일러의 의무는 이 추상 기계의 관찰 가능한 동작(observable behavior)을 보존하는 것입니다. 관찰 가능한 동작이 동일하다면 내부 구현을 얼마든지 변경할 수 있습니다.
이 개념이 왜 중요한지는, 정수 타입의 동작을 살펴보면 체감할 수 있습니다. 특히 unsigned와 signed가 범위를 넘었을 때 왜 완전히 다르게 처리되는지를 이해하는 열쇠가 됩니다.
부호있는 정수는 한계가 있다.
아무래도 이 책의 대상과 목적은 기초지식을 위하지는 않습니다. 부호가 있는 정수타입은 한계를 넘어선 연산을 시도할 때 미정의 동작 (undefined behavior, UB) 이라고 합니다. (다행스럽게도 이건 업무 중에도 들어봤습니다.)
int b = INT_MAX + 1; // UB: 결과를 보장할 수 없음
흔히들 발생하는 문제 상황이지만, 컴파일러는 이 상황을 야기한 개발자를 제정신이 아닌 것쯤으로 취급하고 이런 상황을 가정하지 않습니다. (멋진 최적화의 근원입니다.) 추상 기계의 핵심이 이 지점에서 동작합니다.
이를 as-if 규칙이라고 합니다. 프로그램은 추상 기계를 따르는 것으로 실행됩니다.
이 논리를 정리하면 이렇습니다.
- signed type의 overflow 는 UB다.
- 올바른 프로그램은 UB가 발생하지 않는다.
- signed signed시작됩니다 type 의 연산 결과는 항상 표현 범위 내에 존재한다.
- 이 전제를 따르도록 과감하게 코드를 단순화 한다.
아래의 코드를 살펴보겠습니다.
int x;
// ...
if (x + 1 > x) {
do_something();
}
... 사이에 x라는 값을 지지고 볶는다고 하더라도
컴파일러에게는,
- x + 1이 overflow하는 경우? 그건 UB니까 "발생하지 않는다"고 가정
- overflow가 없다면 x + 1 > x는 수학적으로 항상 참
- 그러면 조건문 자체가 필요 없으니 do_something()을 무조건 실행
결과적으로
do_something();
한줄로 바뀝니다.
또 다른 예시로는 반복문도 대표적입니다.
for (int i = 0; i < N; i++) {
a[i] = i * 4;
}
컴파일러는 i*4가 overflow하지 않는다고 확신하기 때문에 반복문에서 곱셈대신 덧셈으로 변경합니다. (그 편이 더 저렴하니까.)
int *p = a;
int *end = a + N;
int val = 0;
while (p < end) {
*p++ = val;
val += 4;
}
하지만 unsigned 는 상황이 바뀝니다.
부호없는 정수는 끝없이 순환한다.
부호없는 정수는 하드웨어 단위의 메모리 공간을 최대한 사용하기 위해 비트 그대로 사용합니다. 8비트 레지스터는 물리적으로 8개의 비트만 저장할 수 있습니다. 255 + 1을 계산하면 이런 일이 벌어집니다.
11111111 (255)
+ 00000001 ( 1)
----------
100000000 (256, 9비트)
00000000 ( 0, 상위 비트 잘림)
9번째 비트는 레지스터에 담을 수 없으니 그냥 잘려나갑니다. 이 "비트 잘림"이 수학적으로는 2^N으로 나눈 나머지를 취하는 것과 정확히 같은 결과를 만들어냅니다. C 표준은 이 하드웨어의 자연스러운 동작을 그대로 언어 명세로 보장한 겁니다.
뺄셈도 동일합니다.
00000000 ( 0)
- 00000001 ( 1)
----------
11111111 (255)
2의 보수 체계에서 -1의 비트 패턴이 11111111인데, 이걸 unsigned로 해석하면 255가 됩니다.
하지만 이건 오버플로우라고 부르지 않습니다. 의도된 동작이기 때문입니다.
때문에 unsigned는 다르게 동작합니다.
unsigned int y;
if (y + 1 > y) { ... }
y == UINT_MAX이면 y + 1은 0으로 래핑되니까 조건이 거짓이 됩니다. 이 경우 컴파일러는 조건을 함부로 제거할 수 없습니다.
overflow(= UB)라면 컴파일러가 "그런 일은 일어나지 않는다"고 가정할 수 있고, 래핑(= 정의된 동작)이라면 가정할 수 없습니다.
마치며
- 추상 기계 (abstract machine): C 표준이 정의하는 실행 모델. 실제 CPU가 아니라 컴파일러가 준수해야 하는 스펙입니다.
- as-if rule: 관찰 가능한 동작만 보존하면 추상 기계와 다르게 구현해도 된다는 원칙. 컴파일러 최적화의 근거입니다.
- 추상 해석 (abstract interpretation): 컴파일러가 내부적으로 사용하는 정적 분석 기법. 변수의 가능한 값 범위를 추적하여 최적화에 활용합니다.
참고 문헌
- Gustedt, J. (2019). Modern C. Manning Publications.