Devin.KR

C · 기본

메모리 그림으로 배우는 C

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

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보다 작지 않다. 버퍼 길이 검사에서 이런 식을 쓰면 빈 입력일 때 검사를 통째로 건너뛰는 보안 버그가 된다.

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

READER FEEDBACK

질문·오탈자·의견

내용에 관한 질문이나 오탈자, 더 나은 설명을 위한 의견을 남겨 주세요. 이 댓글은 원래 게시글과 같은 자리에 쌓입니다.

댓글 0

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

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