Devin.KR

C 연산자와 형 변환 함정 - 정수 나눗셈 승격 부호 비교 우선순위 (C 기초 3장)

개발자 조회 1

이 장에서 배우는 것

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 만 남는다.

unsigned char 두 개는 int 로 승격돼 300 을 정확히 계산하지만, 그 결과를 1바이트에 담으면 낮은 바이트 44 만 남는다.

int -1 과 unsigned int 1 을 비교하면 -1 이 unsigned 로 바뀌어 4294967295 가 되므로 -1 < 1 이 거짓이 된다.

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 < limitintunsigned 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

한눈에 보기

자주 틀리는 연산자 우선순위 (위가 먼저 계산)
순서연산자주의할 점
1a[i] f() p->x x++후위 연산이 가장 먼저 붙는다
2*p &x ! (type) sizeof*p++*(p++)
3* / % 다음 + -수학과 같다
4<< >>1 << n + 11 << (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 → int0 쪽으로 버림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다. 한 줄에 한 번만 바꾼다.

연습 문제

  1. int total = 10, n = 4;일 때 double avg = total / n;의 값은? 2.5가 나오도록 고쳐라.
  2. 하드웨어 상태 레지스터 값 status에서 3번 비트(0x08)가 켜져 있는지 검사하는 조건식을 써라. status & 0x08 == 0x08이 왜 틀렸는지도 설명하라.
  3. size_t len = 0; if (len - 1 < 10) { ... }의 본문은 실행되는가? 이유를 설명하라.

정답

  1. total / n이 정수 나눗셈이라 2가 되고, 이것이 double로 바뀌어 2.0이 된다. double avg = (double)total / n;으로 한쪽을 나누기 전에 바꾼다.
  2. (status & 0x08) != 0. 틀린 식은 ==가 먼저 계산되어 status & (0x08 == 0x08), 즉 status & 1이 된다. 0번 비트를 검사하는 셈이다. -Wall-Wparentheses 경고로 알려 준다.
  3. 실행되지 않는다. len이 부호 없는 size_t이므로 0 - 1은 순환해서 SIZE_MAX(이 기계에서 18446744073709551615)가 되고, 10보다 작지 않다. 버퍼 길이 검사에서 이런 식을 쓰면 빈 입력일 때 검사를 통째로 건너뛰는 보안 버그가 된다.

다음 장에서는 이 계산들을 조건문과 반복문으로 엮고, 컴파일러가 대신 잡아 주는 제어문 실수들을 본다.

댓글 0

아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.

댓글을 남기려면 로그인이 필요합니다.