구현 정의 동작
고친 사람 github-actions[bot]
구현 정의 동작은 코드가 낼 값을 언어 대신 컴파일러가 정하게 합니다. 컴파일러는 무엇으로 정했는지 설명서에 밝혀 둬야 합니다. 그래서 같은 코드가 컴파일러마다 다른 값을 낼 수 있습니다. C 와 C++ 코드를 다른 플랫폼으로 옮길 때 자주 걸리는 지점입니다.
쉽고 빠른 이해
구현 정의 동작은 언어가 값을 비워 두고 컴파일러가 채우게 한 동작입니다. C 에서 int 가
몇 비트인지가 그렇습니다.
이렇게 비워 두면 컴파일러가 기계마다 가장 잘 맞는 값을 고릅니다. 모든 기계에 한 값을 강제하면 어떤 기계에서는 코드가 느려집니다.
- 언어는 「이 값은 컴파일러가 정한다」고만 적습니다
- 컴파일러는 대상 기계에 맞춰 값을 고릅니다. 고른 값은 설명서에 적습니다
- 그 컴파일러로 빌드한 프로그램은 늘 같은 결과를 냅니다
대가는 옮기기 어려워진다는 것입니다. 한 컴파일러에서 맞던 가정이 다른 컴파일러에서는 틀릴 수 있습니다.
상세
본사가 사내 규정에 「출장 식비 한도는 각 지사가 정해 게시판에 공지한다」고 적었다고 해 봅시다. 서울 지사와 부산 지사는 서로 다른 한도를 정할 수 있습니다. 대신 직원은 자기 지사 게시판만 보면 한도를 압니다.
프로그래밍 언어에도 이런 규정이 있습니다. 언어의 규칙을 적은 공식 문서를 언어 표준이라고 부릅니다. 언어 표준은 대부분의 동작에 결과를 하나로 정합니다. 몇몇 동작은 결과를 비워 두고 컴파일러에게 맡깁니다.
구현 정의 동작은 그렇게 맡긴 동작 가운데, 무엇을 골랐는지 컴파일러가 문서로 밝혀야 하는
동작입니다. 이 이름에서 「구현」은 표준을 따라 만든 컴파일러와 그 표준 라이브러리를
묶어 부르는 말입니다. 표준 라이브러리는 printf 처럼 언어와 함께 딸려 오는 기본 함수 묶음입니다.
구현마다 값을 정한다고 해서 이런 이름이 붙었습니다.
C 의 char 가 대표입니다. char 는 한 바이트짜리 타입입니다. 이 타입이 음수를 담을 수
있는지를 컴파일러가 정합니다.
char c = '\xff';
printf("%d\n", c); // -1 또는 255
'\xff' 는 값이 255 인 한 바이트입니다. 비트로 적으면 여덟 칸이 모두 1 인 1111 1111 입니다.
printf 줄은 c 를 정수로 찍습니다.
char 가 음수를 담는 타입인 컴파일러에서는 이 바이트가 -1 로 읽힙니다. 음수를 안 담는 타입인
컴파일러에서는 255 가 나옵니다. 바이트는 같고 읽는 법만 갈립니다.
flowchart TD
B["바이트 1111 1111"]
B --> S["char 가 음수를 담는 컴파일러"]
B --> U["char 가 음수를 안 담는 컴파일러"]
S --> S1["-1"]
U --> U1["255"]
두 결과 모두 틀린 것이 아닙니다. 각 컴파일러는 자기가 고른 쪽을 설명서에 적어 두었습니다. 그 컴파일러로 빌드한 프로그램은 늘 같은 값을 냅니다. C++ 도 같은 구분을 씁니다.
값을 비워 두는 까닭
언어가 왜 값을 정하지 않았는지를 기계마다 다른 수의 크기로 봅니다.
C 는 여러 종류의 기계에서 돌도록 만든 언어입니다. 프로세서는 종류마다 한 번에 다루는 수의 크기가 다릅니다. 이 크기를 워드 크기라고 부릅니다.
int 를 모든 기계에서 32비트로 고정했다고 해 봅시다. 16비트 기계는 int 하나를 더할
때마다 명령을 두 번 써야 합니다. 그래서 C 는 int 가 16비트 이상이라는 것만 정했습니다. 크기는
컴파일러에게 맡겼습니다. 컴파일러는 그 기계가 가장 빨리 다루는 크기를 고릅니다.
이것이 구현 정의 동작이 있는 이유입니다. 기계마다 가장 자연스러운 답을 쓰게 하면 코드가 빨라집니다. 그 기계용 컴파일러를 만들기도 쉬워집니다.
대가는 이식성입니다. 이식성은 프로그램을 다른 환경으로 옮길 때 고칠 곳이 적은 성질입니다. 기계를 바꾸면 맞던 가정이 틀어지므로 그만큼 고칠 곳이 늡니다.
결과를 비워 두기만 하면 프로그래머는 기댈 곳이 없습니다. 그래서 컴파일러에게 고른 값을 밝힐 의무를 함께 지웠습니다. 프로그래머는 자기가 쓰는 컴파일러의 설명서를 보면 답을 압니다.
Java 는 반대쪽을 골랐습니다. int 는 어디서나 32비트입니다. char 는 언제나 음수를 안 담는
16비트입니다. 크기와 부호를 언어가 못 박아서 이런 갈림이 적습니다. 대신 언어가 정한 크기가
맞지 않는 기계에서는 명령을 더 씁니다.
자주 만나는 구현 정의 동작
C 에서 흔히 부딪히는 구현 정의 동작을 먼저 표로 모읍니다. 그 뒤에 표에 없는 하나를 코드로 봅니다.
표를 읽기 전에 낱말 둘을 짚습니다. 부호 있는 타입은 음수도 담는 정수 타입입니다. 부호 없는 타입은 0 이상만 담는 대신 같은 크기로 양수를 두 배까지 담습니다.
| 동작 | 언어가 정한 것 | 컴파일러가 고르는 것 |
|---|---|---|
int 의 크기 |
16비트 이상 | 16비트 · 32비트 등 |
long 의 크기 |
32비트 이상 | 32비트 · 64비트 등 |
char 의 부호 |
부호 있음과 없음 중 하나 | 둘 중 하나 |
| 한 바이트의 비트 수 | 8 이상 | 거의 모든 플랫폼에서 8 |
| 범위를 넘는 값을 부호 있는 타입에 넣은 결과 | 컴파일러가 정해 밝힌다 | 어떤 값이 남을지 |
크기를 다루는 행에서 보듯 언어는 하한만 정합니다. 나머지는 컴파일러에게 맡깁니다. 그래서
long 을 64비트(8바이트)로 가정하고 파일에 수를 써 두면, long 이 32비트(4바이트)인
플랫폼에서 그 파일을 잘못 읽습니다. 8바이트로 쓴 수를 4바이트씩 끊어 읽어 값 하나가 둘로
쪼개집니다.
표에 없는 하나는 음수를 오른쪽으로 미는 일입니다. 오른쪽 비트 시프트는 수의 비트를 오른쪽으로 한 칸씩 밀어냅니다. 양수에서는 2로 나눈 것과 같습니다.
음수를 밀 때 무엇이 갈리는지 보려면 음수가 비트로 어떻게 생겼는지부터 알아야 합니다. 부호 있는 타입은 맨 왼쪽 비트로 부호를 나타냅니다. 이 비트를 부호 비트라고 부릅니다. 음수면 1 이고 0 이상이면 0 입니다.
거의 모든 기계는 음수를 2의 보수라는 방식으로 비트에 담습니다. 이 방식에서 32비트 int 의
-8 은 끝 네 비트가 1000 입니다. 부호 비트와 끝 네 비트 사이의 비트는 전부 1 입니다.
오른쪽으로 한 칸 밀면 맨 왼쪽 칸이 비워집니다. 음수에서는 이 칸을 무엇으로 채울지가 갈립니다. C 는 어느 쪽을 쓸지 컴파일러에게 맡깁니다.
부호 비트를 복사해 채우는 방법을 산술 시프트라고 부릅니다. 비워진 칸에 다시 1 이 들어가서 수가 음수로 남습니다.
0 으로 채우는 방법은 논리 시프트라고 부릅니다. 맨 왼쪽이 0 이 되니 수가 양수로 바뀝니다.
나머지 비트는 거의 1 이라 int 가 담는 가장 큰 수에 가까운 양수가 됩니다.
아래 그림은 -8 을 한 칸 민 두 결과입니다. 사이 비트는 전부 1 이라 접어 그렸습니다. 두 줄이 갈리는 칸은 부호 비트 하나뿐입니다.
block-beta columns 4 h0["값"] h1["부호 비트"] h2["사이 비트"] h3["끝 네 비트"] a0["-8"] a1["1"] a2["1 … 1"] a3["1000"] b0["산술 시프트: -4"] b1["1 · 복사"] b2["1 … 1"] b3["1100"] c0["논리 시프트: 큰 양수"] c1["0 · 새로 채움"] c2["1 … 1"] c3["1100"]
int 가 32비트인 컴파일러에서 이것을 코드로 적으면 아래와 같습니다.
int x = -8;
int y = x >> 1; // -4 또는 2147483644
산술 시프트를 고른 컴파일러에서는 부호 비트가 남아 -4 가 나옵니다. 논리 시프트를 고른 컴파일러에서는 부호 비트가 0 이 되어 2147483644 가 나옵니다. 흔히 쓰는 컴파일러는 대부분 산술 시프트를 고릅니다. 그래도 이것은 관례일 뿐입니다. 언어가 보장한 것이 아닙니다.
Java 는 이 갈림도 코드에 드러내게 했습니다. >> 는 언제나 산술 시프트입니다. >>> 는 언제나
논리 시프트입니다.
이웃한 세 동작
구현 정의 동작 곁에는 이웃한 동작이 셋 있습니다. 결과를 하나로 정하지 않은 미지정 동작과 정의되지 않은 동작, 그리고 결과가 하나로 정해진 정의된 동작입니다. 구현 정의 동작까지 넣어 넷을 견줍니다.
가르는 질문은 둘입니다. 하나는 컴파일러가 결과를 고를 수 있느냐입니다. 다른 하나는 고른 것을 문서에 밝혀야 하느냐입니다.
| 결과를 고를 수 있나 | 문서에 밝히나 | 예 | |
|---|---|---|---|
| 정의된 동작 | ✗ | 해당 없음 | 1 + 2 는 3 |
| 구현 정의 동작 | ✓ 허용된 범위 안에서 | ✓ | char 의 부호 |
| 미지정 동작 | ✓ 허용된 범위 안에서 | ✗ | 함수 인자를 계산하는 순서 |
| 정의되지 않은 동작 | 허용 범위 자체가 없음 | ✗ | 배열 끝을 넘어 읽기 |
미지정 동작은 허용된 결과 가운데 하나가 나온다는 것만 약속합니다. f(a(), b()) 에서 a() 와
b() 중 무엇이 먼저 불릴지가 그렇습니다. 컴파일러는 이것을 밝힐 의무가 없습니다. 같은 프로그램
안에서도 호출마다 다르게 고를 수 있습니다.
정의되지 않은 동작에는 아무 약속도 없습니다. 프로그램이 죽어도, 엉뚱한 값을 내도, 멀쩡해 보여도 전부 허용됩니다. 구현 정의 동작은 설명서를 보면 결과를 압니다. 정의되지 않은 동작은 설명서를 봐도 결과를 모릅니다.
두 칸 사이에는 헷갈리기 쉬운 짝이 있습니다. 하나는 범위를 넘는 값을 부호 있는 타입에 넣는 것입니다. 다른 하나는 부호 있는 타입끼리 더하다가 범위를 넘는 정수 오버플로입니다.
signed char 는 음수도 담도록 정해 둔 한 바이트 타입입니다. 8비트 바이트라면 보통 -128 부터
127 까지 담습니다. 아래 첫 줄은 여기에 200 을 넣습니다. 둘째와 셋째 줄은 32비트 int 가 담는
가장 큰 수에 1 을 더합니다.
signed char c = 200; // 컴파일러가 밝힌 값
int n = 2147483647;
n = n + 1; // 아무 약속 없음
둘 다 「값이 넘쳤다」로 보입니다. 그런데 첫 줄은 구현 정의 동작이라 남는 값이 설명서에 있습니다. 셋째 줄은 정의되지 않은 동작이라 설명서에도 답이 없습니다.
구현 정의 동작에 기대는 코드 다루기
구현 정의 동작을 아예 피할 수는 없습니다. int 하나를 쓰는 순간 이미 기댑니다. 그래서 피하기보다
그 가정을 코드에 드러냅니다. 방법은 셋입니다.
첫째, 값을 가정하지 말고 컴파일러에게 묻습니다. 물을 때는 헤더 파일을 씁니다. 헤더 파일은 여러 파일이 함께 가져다 쓰는 선언을 모아 둔 파일입니다.
그중 <limits.h> 는 컴파일러가 고른 값을 상수로 줍니다. CHAR_BIT 는 한 바이트의 비트
수입니다. INT_MAX 는 int 가 담는 가장 큰 수입니다.
둘째, 크기가 중요한 곳에는 크기가 정해진 정수 타입을 씁니다. <stdint.h>
의 int32_t 는 크기가 32비트로 정해진 정수 타입입니다. 파일에 쓰거나 네트워크로 주고받는
수에는 이런 타입을 씁니다.
셋째, 가정을 빌드할 때 검사합니다. 정적 단언은 조건이 거짓이면 컴파일을 멈추게 하는 문장입니다.
static_assert(CHAR_BIT == 8, "8비트 바이트");
이 줄은 한 바이트가 8비트가 아닌 플랫폼에서 빌드를 멈춥니다. 가정이 틀린 채로 프로그램이 도는 대신 빌드 단계에서 틀린 것이 드러납니다.
백엔드 개발자는 C 를 직접 짜지 않아도 이 동작을 만납니다. PostgreSQL 같은 데이터베이스 엔진이나 파이썬의 C 확장 모듈처럼 C 로 짠 라이브러리를 다른 플랫폼에서 다시 빌드할 때입니다. 다시 빌드한 뒤 값이 어긋나면 구현 정의 동작에 기댄 코드부터 의심합니다.
관련 항목
결과를 정하는 정도로 가른 동작 분류
정의된 동작 · 미지정 동작 · 정의되지 않은 동작 · 로캘 특정 동작
컴파일러가 고르는 값과 연산
워드 크기 · 정수 타입 · 부호 있는 정수 · 부호 없는 정수 · 바이트 · 비트 · 비트 시프트 · 산술 시프트 · 논리 시프트 · 2의 보수 · 정수 변환 · 정수 오버플로
구현 정의 동작에 기댄 가정을 드러내는 수단
헤더 파일 · limits.h · stdint.h · 고정 폭 정수 타입 · 정적 단언 · 조건부 컴파일 · 전처리기
구현 정의 동작을 정의하는 언어와 표준
C · C++ · 언어 표준 · ISO/IEC 9899 · 컴파일러 · 표준 라이브러리
구현 정의 동작 때문에 어긋나는 성질과 작업
이식성 · 포팅 · 크로스 컴파일 · 플랫폼 · 엔디언 · ABI
크기와 부호를 언어가 못 박은 언어
다른 이름: implementation-defined behavior · implementation-defined behaviour · 구현 정의 행동 · 구현 의존 동작