Proven C BookEnglish GitHub

28 정수의 연산 — 나눗셈, 비트

먼저 알아야 할 것

7장 정수의 표현 · 비트와 시프트
27장 정수 · 정수의 유한함

돌아보기

20장에서 “7 / 2는 3.5가 아니다”라고 예고만 했다. 7장의 지식으로 스스로 답해 보면 — 정수 그릇끼리의 나눗셈(division) 결과가 3.5일 수 없는 이유는 무엇인가?

답. 3.5를 담을 자리가 없기 때문이다. 정수 타입의 값 집합에는 3과 4만 있고 그 사이가 없다 — 그래서 정수끼리의 나눗셈은 결과도 정수여야 하고, 소수 부분을 어느 쪽으로든 버려야 한다. 문제는 “어느 쪽으로”다 — 그 규칙이 이 장의 첫 절이다.

이 장의 필요성과 맥락

20장에서 「나눗셈은 나중에」라고 미뤄 둔 약속을 갚는 자리다. 미룬 이유가 이제 분명하다 — 정수 나눗셈의 진실은 정수 타입(27장)을 알아야 말이 되기 때문이다. 비트 연산도 같다: 7장의 개념을 이제 연산자로 받는다. 표현에서 타입으로, 타입에서 연산으로 가는 세 걸음의 마지막이다.

이 장이 끝나면

20장에서 미뤄 둔 약속을 지킨다 — 정수 나눗셈 /와 나머지 %의 진실, 특히 음수에서의 규칙까지. 그리고 7장에서 개념으로 배운 비트 연산들이 C의 연산자로 합류한다. 형변환의 첫걸음도 여기서 뗀다.

이 장에서 답할 질문

  1. 0으로 나누면 어떻게 되는가?

28.1 나눗셈과 나머지 — 버림의 방향

시연부터 본다.

examples/ch28/divmod.c

#include <stdio.h>

int main(void)
{
    printf(" 7 / 2 = %d,  7 %% 2 = %d\n", 7 / 2, 7 % 2);
    printf("-7 / 2 = %d, -7 %% 2 = %d\n", -7 / 2, -7 % 2);
    printf("invariant: (7/2)*2 + 7%%2 = %d\n", (7 / 2) * 2 + 7 % 2);

    printf("5 & 3 = %d, 5 | 3 = %d, 5 ^ 3 = %d\n", 5 & 3, 5 | 3, 5 ^ 3);
    printf("1 << 4 = %d\n", 1 << 4);
    return 0;
}

실행 결과

 7 / 2 = 3,  7 % 2 = 1
-7 / 2 = -3, -7 % 2 = -1
invariant: (7/2)*2 + 7%2 = 7
5 & 3 = 1, 5 | 3 = 7, 5 ^ 3 = 6
1 << 4 = 16

양수는 직관대로다 — 7 / 2는 3, 나머지는 1. 규칙이 필요한 곳은 음수다. C는 0을 향해 버린다(truncation toward zero) — -7 / 23.5에서 0 쪽으로 버린 3이고, 나머지는 그에 맞춰 1이다. “수학의 몫·나머지(나머지는 항상 0 이상)”와 다른 선택이라서, 음수 나머지를 쓰는 코드는 이 차이를 모르면 사고가 난다.

수학. 나눗셈-나머지 복원 불변식

방향이 무엇이든, C가 반드시 지키는 등식이 하나 있다:

(𝑎𝑏)×𝑏+(𝑎%𝑏)=𝑎

몫과 나머지는 이 등식을 만족하도록 한 쌍으로 정의된다 — 시연의 마지막 줄이 이 등식의 확인이다. 0을 향한 버림을 택하면 나머지의 부호가 피제수(𝑎)를 따르게 되는 것도 이 등식의 귀결이다. 덧붙여 역사 한 줄 — C89까지는 버림의 방향이 구현마다 달라도 됐고, C99가 “0을 향해”로 못박았다. 7장에서 본 “기계들이 갈리면 표준은 비워 둔다”가, 기계들이 수렴하자 약속으로 승격된 또 하나의 사례다.

문. 0으로 나누면 어떻게 되는가?

답. 계약 밖 — 정의되지 않은 동작이다. 수학에서 정의되지 않은 것이 C 에서도 정의되지 않은 드문 일치 사례인데, 결과는 수학처럼 얌전하지 않다: 많은 기계에서 프로그램이 그 자리에서 붕괴하고, 어떤 최적화 아래서는 더 기묘한 일도 벌어진다(52장). 나누기 전에 0인지 확인하는 것은 프로그래머의 몫이고 — 31장에서 분기를 배웠으니 이제 그 확인을 코드로 적을 수 있게 된다.

28.2 비트 연산 — 7장의 세계, C의 문법

7장에서 개념으로 배운 비트 다루기가 연산자로 합류한다 — AND &, OR |, XOR ^, 반전 ~, 그리고 시프트 << >>. 시연의 뒷부분이 그 맛보기다: 5 & 3101 & 011 = 001이라 1, 5 | 3111이라 7, 1 << 4는 7장의 약속대로 24=16이다.

실무의 기본 무늬는 7장에서 예고한 그대로 시프트+마스크다 — 원하는 자리로 밀고(<<, >>), 필요한 비트만 남긴다(&). 다만 두 가지 수칙을 함께 챙긴다. 첫째, 비트 연산은 부호 없는 타입에서 한다 — 부호 있는 수의 시프트에는 7장에서 본 함정들(폭 이상, 음수 왼쪽 시프트=계약 밖)이 있어서, unsigneduint32_t 위에서 노는 것이 안전 관행이다. 둘째, &(비트 AND)와 &&(논리 AND, 다음 장)는 완전히 다른 연산자다 — 한 글자 차이가 값 전체를 바꾼다.

28.3 이 장의 연산자들이 거는 계약

여기까지 나온 연산자들을 계약의 눈으로 한 번에 정리한다. 각 연산자가 무엇을 받고, 어디서부터 계약 밖인지다(전수 표는 부록 A에 있다).

연산자피연산자에 거는 조건계약 밖 / 회색지대
/ %%정수만, /는 실수도 된다제수가 0이면 계약 밖. INT_MIN / -1INT_MIN % -1도 마찬가지(몫이 int에 안 담긴다)
+ - *산술 타입부호 있는 정수의 넘침은 계약 밖(27장). 부호 없는 쪽은 감아 돈다
& | ^ ~정수만부호 있는 타입 위에서는 부호 비트까지 다루게 된다 — 무부호를 쓴다
<< >>양쪽 다 정수아래 표 참조

표 28.1

시프트는 회색지대가 셋이라 따로 적는다. 이 셋은 판이 바뀌어도 그대로다.

상황판정설명
x << n 또는 x >> n에서 n < 0 또는 n >= 폭계약 밖“폭”은 승격된 왼쪽 피연산자의 비트 수다. int가 32비트면 1 << 32부터 계약 밖
x가 부호 있고 음수인데 x << n계약 밖C23에서도 그렇다
x가 부호 있고 양수인데 결과가 안 담기는 x << n계약 밖1 << 31int에서 그렇다 — 1u << 31로 쓴다
x가 부호 있고 음수인데 x >> n구현 정의대개 산술 시프트(부호 유지)지만 표준의 약속이 아니다

표 28.2

흔한 오해. “C23이 두 보수를 강제했으니 음수 시프트 문제도 사라졌다”

두 보수 표현이 강제된 것은 맞다(78장). 그러나 시프트 조항은 손대지 않았다 — 부호 있는 음수의 좌시프트는 C23에서도 계약 밖이고, 음수의 우시프트는 여전히 구현 정의다. gcc와 clang이 산술 시프트로 동작하는 것은 그 구현의 약속이지 표준의 약속이 아니다.

그래서 이 책의 수칙은 판이 올라가도 같다 — 시프트는 무부호 위에서 한다. 부호 있는 값을 밀어야 하면 무부호로 옮겨 밀고 되돌리며, 횟수는 언제나 0 <= n < 폭인지 확인한다. 폭은 sizeof(x) * CHAR_BIT로 얻는다.

28.4 형변환 — 그릇 사이를 건너다

타입 가족(27장)이 생기니 새 질문이 따라온다 — 다른 그릇끼리 섞어 계산하면 무슨 일이 일어나는가. C의 답은 암묵 변환이다: 작은 그릇의 값은 계산 전에 자동으로 더 큰 그릇으로 넓혀지고(7장의 부호 확장이 바로 이때 일어나는 일이다), 정수와 실수가 만나면 정수가 실수로 승격된다. 대부분은 뜻대로 되지만, 자동이라는 것은 안 보인다는 뜻이기도 하다 — 특히 부호 있는 것과 없는 것이 섞이는 비교는 고전적 함정이라(음수가 거대한 양수로 둔갑한다), 컴파일러 경고(-Wall)가 지켜 주는 대표 지점이다.

변환을 명시하고 싶으면 캐스트 표기를 쓴다 — (double)7 / 2는 “7을 실수 그릇으로 옮긴 뒤 나눠라”이므로 3.5가 된다. 규칙의 전모 (정수 승격, 통상 산술 변환)는 부록 참조 자료로 두고, 본문 수칙은 둘이다: 섞이는 계산은 의도를 캐스트로 명시하고, 부호 섞인 비교는 피한다.

정수의 그릇(27장)과 연산(28장)을 갖췄다. 다음 장부터는 흐름이다 — 비교하고 판단하는 값, 불리언(boolean)부터.