C 연산자와 형 변환 함정 - 정수 나눗셈 승격 부호 비교 우선순위 (C 기초 3장)
이 장에서 배우는 것
2장에서 같은 바이트도 자료형에 따라 다른 값이 된다는 것을 봤다. C는 서로 다른 자료형끼리 계산할 때 눈에 보이지 않는 변환을 끼워 넣는다. 이 변환이 대부분은 편리하지만, 몇 가지 규칙을 모르면 계산 결과가 조용히 틀린다.
- 정수 나눗셈과 나머지의 규칙(0 쪽으로 버림)을 안다.
- 정수 승격(작은 정수형이
int로 바뀌는 것)과 좁은 자료형에 담을 때의 잘림을 구분한다. - 부호 있는 수와 부호 없는 수를 비교할 때 생기는 함정을 컴파일러 경고와 함께 확인한다.
- 비트 연산자와 비교 연산자의 우선순위 함정, 부동소수 비교와 절삭의 함정을 피한다.
문제 상황
게시판 페이지 수를 계산했더니 글이 101개일 때 페이지가 5개로 나와 마지막 글 하나가 사라졌다. 권한 비트를 검사하는 if는 쓰기 권한이 있는 사용자를 계속 거절한다. 할인액은 5,700원의 35%인데 1,994원이 나왔다. 셋 다 연산 한 줄에서 생긴 문제이고, 원인은 각각 정수 나눗셈, 연산자 우선순위, 부동소수에서 정수로의 절삭이다.
메모리 그림: 계산은 int 크기의 작업대에서
unsigned char a = 200; [ c8 ] 1바이트
unsigned char b = 100; [ 64 ] 1바이트
a + b 를 계산할 때 (정수 승격)
a → int [ c8 00 00 00 ]
b → int [ 64 00 00 00 ]
합 int [ 2c 01 00 00 ] = 300 4바이트 작업대에서 계산
unsigned char stored = 300;
낮은 1바이트만 남음 [ 2c ] = 44 나머지 바이트는 버려짐
int balance = -1 과 unsigned int limit = 1 비교
balance 가 unsigned 로 바뀜 [ ff ff ff ff ] = 4294967295
4294967295 < 1 ? → 거짓
int보다 작은 정수형(char, short)은 계산하기 전에 int로 승격된다. 두 피연산자의 자료형이 다르면 "공통 자료형"으로 맞춘 뒤 계산하는데, 같은 크기의 부호 있는 수와 부호 없는 수가 만나면 부호 없는 쪽으로 맞춘다. 음수가 거대한 양수로 바뀌는 것은 이 규칙 때문이다.
unsigned char 두 개는 int 로 승격돼 300 을 정확히 계산하지만, 그 결과를 1바이트에 담으면 낮은 바이트 44 만 남는다.
int -1 과 unsigned int 1 을 비교하면 -1 이 unsigned 로 바뀌어 4294967295 가 되므로 -1 < 1 이 거짓이 된다.
완성 코드
나눗셈과 나머지
#include <stdio.h>
int main(void) {
int total = 7;
int people = 2;
printf("7 / 2 = %d\n", total / people);
printf("-7 / 2 = %d\n", -total / people);
printf("7 %% 2 = %d\n", total % people);
printf("-7 %% 2 = %d\n", -total % people);
printf("7 / 2.0 = %.1f\n", total / 2.0);
printf("(double)7 / 2 = %.1f\n", (double)total / people);
printf("(double)(7/2) = %.1f\n", (double)(total / people));
int pages = 101;
int per_page = 20;
printf("페이지 수(내림) = %d\n", pages / per_page);
printf("페이지 수(올림) = %d\n", (pages + per_page - 1) / per_page);
return 0;
}
승격과 잘림
#include <stdio.h>
int main(void) {
unsigned char a = 200;
unsigned char b = 100;
int sum = a + b;
unsigned char stored = sum;
printf("int 에 담은 합 = %d\n", sum);
printf("unsigned char 에 담은 합 = %d\n", stored);
printf("sizeof(a + b) = %zu\n", sizeof(a + b));
double ratio = 2.99;
int truncated = (int)ratio;
printf("(int)2.99 = %d\n", truncated);
return 0;
}
부호가 다른 비교
#include <stdio.h>
int main(void) {
int balance = -1;
unsigned int limit = 1;
if (balance < limit) {
printf("-1 < 1 이다\n");
} else {
printf("-1 < 1 이 거짓이 됐다\n");
}
printf("-1 을 unsigned 로 보면 %u\n", (unsigned int)balance);
return 0;
}
우선순위 함정
#include <stdio.h>
#define FLAG_READ 0x1
#define FLAG_WRITE 0x2
int main(void) {
int flags = FLAG_WRITE;
if (flags & FLAG_WRITE == FLAG_WRITE) {
printf("틀린 식: 쓰기 권한 있음\n");
} else {
printf("틀린 식: 쓰기 권한 없음\n");
}
if ((flags & FLAG_WRITE) == FLAG_WRITE) {
printf("괄호 식: 쓰기 권한 있음\n");
} else {
printf("괄호 식: 쓰기 권한 없음\n");
}
return 0;
}
부동소수 비교와 절삭
#include <stdio.h>
#include <math.h>
int main(void) {
double x = 0.1 + 0.2;
printf("0.1 + 0.2 = %.17g\n", x);
printf("x == 0.3 ? %d\n", x == 0.3);
printf("|x - 0.3| < 1e-9 ? %d\n", fabs(x - 0.3) < 1e-9);
int won = 5700;
double rate = 0.35;
printf("할인액(double→int 버림) = %d\n", (int)(won * rate));
printf("할인액(정수로 계산) = %d\n", won * 35 / 100);
return 0;
}
줄별 해설
total / people— 두 피연산자가 모두 정수면 결과도 정수이고, 소수부는 0 쪽으로 버린다(C99부터 표준으로 정해짐). 그래서 -7 / 2는 -4가 아니라 -3이다.-total % people— 나머지의 부호는 왼쪽 피연산자를 따른다.a == (a / b) * b + a % b가 항상 성립하도록 맞춘 규칙이다. "짝수인가"를x % 2 == 1로 검사하면 음수 홀수를 놓친다.x % 2 != 0으로 쓴다.(double)total / people와(double)(total / people)— 앞은 나누기 전에 바꾸므로 3.5, 뒤는 정수 나눗셈 3을 끝낸 뒤 바꾸므로 3.0이다. 괄호 위치 하나 차이다.(pages + per_page - 1) / per_page— 양의 정수의 올림 나눗셈 관용구다. 부동소수와ceil을 쓰지 않아도 된다.int sum = a + b;— 승격 덕분에 300이 정확히 계산된다.sizeof(a + b)가 4인 것이 증거다.unsigned char stored = sum;— 300을 1바이트에 담으면 300 mod 256 = 44만 남는다. 부호 없는 형으로의 변환은 이렇게 나머지를 취하도록 정해져 있어 UB는 아니지만, 대부분 의도하지 않은 결과다.-Wall -Wextra만으로는 경고가 나오지 않고-Wconversion을 더해야 잡힌다.(int)ratio— 부동소수를 정수로 바꾸면 반올림이 아니라 0 쪽으로 버린다. 2.99는 2다.balance < limit—int와unsigned int의 비교다.balance가 부호 없는 수 4294967295로 바뀐 뒤 비교되므로 거짓이다.-Wextra에 포함된-Wsign-compare가 경고한다.flags & FLAG_WRITE == FLAG_WRITE—==가&보다 우선순위가 높다. 그래서flags & (2 == 2), 즉flags & 1이 계산되고 결과는 0이다. 비트 연산은 항상 괄호로 감싼다.x == 0.3— 0.1과 0.2는 이진수로 정확히 표현되지 않아 합이 0.3과 아주 조금 다르다.%.17g로 찍으면 그 차이가 보인다. 측정값은 허용 오차로 비교한다.(int)(won * rate)— 5700 × 0.35의 실제 부동소수 결과는 1995보다 아주 조금 작다. 절삭하면 1994가 된다. 돈은 정수(원 단위)로 계산하고 비율도 정수(퍼센트)로 다뤄won * 35 / 100처럼 쓰면 이 문제가 사라진다.
실제 실행 결과
$ ./division
7 / 2 = 3
-7 / 2 = -3
7 % 2 = 1
-7 % 2 = -1
7 / 2.0 = 3.5
(double)7 / 2 = 3.5
(double)(7/2) = 3.0
페이지 수(내림) = 5
페이지 수(올림) = 6
101개 글을 20개씩 보여 주면 6페이지가 필요한데, 그냥 나누면 5가 나온다. 문제 상황의 첫 번째 사고가 이것이다.
$ ./promotion
int 에 담은 합 = 300
unsigned char 에 담은 합 = 44
sizeof(a + b) = 4
(int)2.99 = 2
-Wconversion을 켜면 잘림이 생기는 대입을 알려 준다.
$ clang -std=c17 -Wall -Wextra -Wconversion promotion.c -o promotion
promotion.c:8:28: warning: implicit conversion loses integer precision: 'int' to 'unsigned char' [-Wimplicit-int-conversion]
8 | unsigned char stored = sum;
| ~~~~~~ ^~~
1 warning generated.
부호가 다른 비교는 -Wextra만으로 경고가 나온다.
$ clang -std=c17 -Wall -Wextra signcmp.c -o signcmp
signcmp.c:7:17: warning: comparison of integers of different signs: 'int' and 'unsigned int' [-Wsign-compare]
7 | if (balance < limit) {
| ~~~~~~~ ^ ~~~~~
1 warning generated.
$ ./signcmp
-1 < 1 이 거짓이 됐다
-1 을 unsigned 로 보면 4294967295
우선순위 함정도 -Wall에 포함된 -Wparentheses가 잡는다. 경고 아래 note 두 줄은 "어떻게 괄호를 칠지"에 대한 제안이다. 의도한 것은 두 번째(&를 먼저 계산)다.
$ clang -std=c17 -Wall -Wextra precedence.c -o precedence
precedence.c:9:15: warning: & has lower precedence than ==; == will be evaluated first [-Wparentheses]
9 | if (flags & FLAG_WRITE == FLAG_WRITE) {
| ^~~~~~~~~~~~~~~~~~~~~~~~~~
precedence.c:9:15: note: place parentheses around the '==' expression to silence this warning
9 | if (flags & FLAG_WRITE == FLAG_WRITE) {
| ^ ~~~~~~~~~~~~~~~~~~~~~~~~
precedence.c:9:15: note: place parentheses around the & expression to evaluate it first
9 | if (flags & FLAG_WRITE == FLAG_WRITE) {
| ~~~~~~^~~~~~~~~~~~
1 warning generated.
$ ./precedence
틀린 식: 쓰기 권한 없음
괄호 식: 쓰기 권한 있음
$ ./floats
0.1 + 0.2 = 0.30000000000000004
x == 0.3 ? 0
|x - 0.3| < 1e-9 ? 1
할인액(double→int 버림) = 1994
할인액(정수로 계산) = 1995
한눈에 보기
| 순서 | 연산자 | 주의할 점 |
|---|---|---|
| 1 | a[i] f() p->x x++ | 후위 연산이 가장 먼저 붙는다 |
| 2 | *p &x ! (type) sizeof | *p++ 는 *(p++) |
| 3 | * / % 다음 + - | 수학과 같다 |
| 4 | << >> | 1 << n + 1 은 1 << (n + 1) |
| 5 | < > 다음 == != | 비교가 비트 연산보다 먼저 |
| 6 | & ^ | | 비트 마스크는 반드시 괄호 |
| 7 | && 다음 || | 단락 평가 |
| 8 | ?: 다음 = += | 대입은 거의 마지막 |
| 상황 | 일어나는 일 | 이 장의 예 |
|---|---|---|
| int 끼리 나눗셈 | 소수부를 0 쪽으로 버림 | -7 / 2 = -3 |
| char·short 계산 | int 로 승격한 뒤 계산 | sizeof(a + b) = 4 |
| 큰 값을 작은 부호 없는 형에 대입 | 낮은 바이트만 남음 | 300 → 44 |
| int 와 unsigned 비교 | int 가 unsigned 로 바뀜 | -1 < 1u 거짓 |
| double → int | 0 쪽으로 버림 | 1994.99… → 1994 |
실무에서 자주 틀리는 것
for (int i = 0; i < strlen(s) - 1; i++)—strlen은 부호 없는size_t를 돌려준다.s가 빈 문자열이면0 - 1이 거대한 수가 되어 반복문이 배열 밖까지 돈다.i + 1 < strlen(s)처럼 빼기를 덧셈으로 옮긴다.- 평균을 정수로 먼저 나누고 나중에 실수로 바꾼다.
double avg = sum / count;는 이미 버린 뒤다.(double)sum / count로 쓴다. - 비트 마스크 검사에 괄호를 뺀다.
&,|,^는 비교 연산자보다 우선순위가 낮다. 레지스터 비트를 다루는 임베디드 코드에서 특히 자주 나오는 버그다. - 돈 계산에
float·double을 쓴다. 최소 단위(원, 센트)의 정수로 계산하고, 비율은 정수 퍼센트나 만분율로 다룬 뒤 반올림 규칙을 명시한다. i = i++ + 1;처럼 한 식에서 같은 변수를 두 번 바꾼다. 순서가 정해지지 않은 수정이라 UB다. 한 줄에 한 번만 바꾼다.
연습 문제
int total = 10, n = 4;일 때double avg = total / n;의 값은? 2.5가 나오도록 고쳐라.- 하드웨어 상태 레지스터 값
status에서 3번 비트(0x08)가 켜져 있는지 검사하는 조건식을 써라.status & 0x08 == 0x08이 왜 틀렸는지도 설명하라. size_t len = 0; if (len - 1 < 10) { ... }의 본문은 실행되는가? 이유를 설명하라.
정답
total / n이 정수 나눗셈이라 2가 되고, 이것이double로 바뀌어 2.0이 된다.double avg = (double)total / n;으로 한쪽을 나누기 전에 바꾼다.(status & 0x08) != 0. 틀린 식은==가 먼저 계산되어status & (0x08 == 0x08), 즉status & 1이 된다. 0번 비트를 검사하는 셈이다.-Wall이-Wparentheses경고로 알려 준다.- 실행되지 않는다.
len이 부호 없는size_t이므로 0 - 1은 순환해서SIZE_MAX(이 기계에서 18446744073709551615)가 되고, 10보다 작지 않다. 버퍼 길이 검사에서 이런 식을 쓰면 빈 입력일 때 검사를 통째로 건너뛰는 보안 버그가 된다.
다음 장에서는 이 계산들을 조건문과 반복문으로 엮고, 컴파일러가 대신 잡아 주는 제어문 실수들을 본다.