26 타입의 갈래 — 표준이 가른 것들
먼저 알아야 할 것
돌아보기
23장에서 int x = 0;을 쓰면서 「타입」이라는 낱말을 계속 썼다. 그러면 타입은 정확히 무엇을 정하는 것인가? 셋만 꼽아 보라.
답. 넷을 꼽을 수 있다.
- 크기 — 이 값이 몇 바이트를 차지하는가(6장).
- 표현 — 그 바이트를 어떤 눈으로 읽는가(7~8장의 2의 보수, IEEE 754).
- 허용되는 연산 — 무엇을 할 수 있는가(
%는 정수만,++는 실수 타입과 포인터만). - 계약 — 컴파일러가 무엇을 검사하고 무엇을 보장하는가.
이 장은 그 「타입」이라는 낱말 아래에 어떤 갈래가 있는지를 표준의 분류 그대로 세운다.
이 장의 필요성과 맥락
이 장이 끝나면
<stdint.h> 의 폭을 이름에 적는 타입들을 얹는다. 다음 장의 정수, 그다음 장의 승격이 전부 이 어휘 위에 선다.이 장에서 답할 질문
void는 「아무것도 없음」인데 왜 타입인가?- 그렇다면
int도signed int와 별개의 타입인가? 같은 논리라면 그래야 할 것 같은데. - 그러면 선언에 올 수 있는 낱말은 한정자와 저장 클래스, 이 둘뿐인가?
- 「선택 사항」이라니 —
uint8_t가 없는 기계가 정말 있는가?
26.1 타입이 정하는 네 가지
같은 여덟 바이트를 놓고도 double로 읽으면 3.14, long으로 읽으면 4614253070214989087이다(8장에서 본 그대로다). 바이트는 자기가 무엇인지 모르고, 아는 것은 타입뿐이다.
| 타입이 정하는 것 | 예 | 자세히 |
|---|---|---|
| 크기 | sizeof(int) = 4 | 6장 |
| 표현 | -1 은 int 로 FF FF FF FF | 7·8장 |
| 허용되는 연산 | % 는 정수만, / 는 산술 타입만 | 28·49장 |
| 계약 | const 는 「바꾸지 않는다」 | 23·51장 |
표 26.1
그래서 「타입을 안다」는 것은 문법을 아는 것이 아니라 이 네 가지를 아는 것이다. 그리고 표준은 그 네 가지를 갈래별로 정해 두었다.
26.2 첫 갈래 — 객체 타입과 함수 타입
가장 위의 가름은 둘이다.
| 갈래 | 무엇인가 | 예 |
|---|---|---|
| 객체 타입(object type) | 값을 담는 자리를 서술한다 | int, double, struct point, int[10], char * |
| 함수 타입(function type) | 일하는 것을 서술한다 — 반환 타입과 매개변수로 | int(void), void(int, char *) |
표 26.2
이 가름이 실무에서 드러나는 자리가 있다. 함수 타입에는 sizeof를 쓸 수 없고, 객체가 아니므로 크기도 정렬도 없다. 함수를 값처럼 다루려면 함수를 가리키는 포인터로 바꿔야 하는 이유가 이것이다(59장).
26.2.1 완전 타입과 불완전 타입
객체 타입은 다시 갈린다 — 크기를 아는가.
| 상태 | 무엇을 못 하는가 | 예 |
|---|---|---|
| 완전 타입(complete type) | (제약 없음) | int, struct point(정의를 본 뒤) |
| 불완전 타입(incomplete type) | sizeof 를 못 쓰고, 그 타입의 객체를 만들 수 없다 | 크기를 안 적은 배열 int a[], 태그만 선언한 struct node |
void | 완성될 수 없는 불완전 객체 타입 | 값의 집합이 비어 있다 |
표 26.3
불완전 타입은 결함이 아니라 도구다. 「이런 타입이 있다는 것만 알고 속은 모른다」를 표현할 수 있어서, 헤더에 속을 감춘 채 포인터만 주고받는 불투명 타입(FILE *가 그 원형이다)이 성립한다. 속을 감추면 쓰는 쪽이 그 속에 의존할 수 없다는 것이 설계의 이득이다(47장).
문. void는 「아무것도 없음」인데 왜 타입인가?
답. 세 자리에서 서로 다른 일을 하기 때문이다.
void f(void)— 「반환할 값이 없다」와 「매개변수가 없다」.(void)printf(…)— 「이 값을 버리겠다」를 명시적으로 적는 캐스트.void *— 「어떤 객체를 가리키는지는 아직 정하지 않았다」. 이것만은 완전한 객체 타입이다(포인터니까 크기가 있다).
표준의 정의는 「값의 집합이 비어 있고, 완성될 수 없는 불완전 객체 타입」이다. 그래서 void 변수는 만들 수 없고 sizeof(void)도 표준에서는 쓸 수 없다 — GCC 는 확장으로 1을 준다(12장의 회색지대).
26.3 기본 타입
표준이 「기본 타입」(basic types)이라 부르는 것은 정확히 이 셋을 합친 것이다.
| 무엇이 기본 타입인가 (§6.2.5p18) | 비고 |
|---|---|
char | 단 하나. 아래의 문자 타입 셋 중 하나 |
| 부호 있는 정수 타입과 부호 없는 정수 타입 | short·int·long·long long과 그 unsigned 짝 |
| 부동소수점 타입 | float·double·long double과 복소수 |
표 26.4
열거 타입은 기본 타입이 아니다. 정수 타입이기는 하지만 기본 타입 목록에는 들어가지 않는다 — 흔히 헷갈리는 자리다.
기본 타입에 대해 표준이 못박는 문장이 하나 더 있다. 「구현이 두 기본 타입에 같은 표현을 주더라도, 그것들은 여전히 별개의 타입이다.」 다음 절의 문자 타입이 그 문장의 대표 사례다.
26.3.1 문자 타입은 셋이다
| 타입 | 부호 | 쓰는 자리 |
|---|---|---|
char | 구현이 정한다 — signed char 또는 unsigned char 와 같은 범위·표현·동작 | 문자를 담을 때 |
signed char | 부호 있음 | 작은 부호 있는 정수 |
unsigned char | 부호 없음 | 바이트를 들여다볼 때(48장) |
표 26.5
핵심은 각주가 못박는 이 문장이다 — char 는 어느 선택을 했든 다른 둘과 별개의 타입이고, 둘 중 어느 것과도 호환되지 않는다. 즉 셋이지 둘이 아니다.
문. 그렇다면 int 도 signed int 와 별개의 타입인가? 같은 논리라면 그래야 할 것 같은데.
답. 아니다. 그리고 이 비대칭이 char 를 특별하게 만드는 바로 그 지점이다. int 와 signed int 는 같은 타입이다 — 철자만 둘일 뿐 하나다. 반면 char 는 signed char·unsigned char 와 세 개의 다른 타입이다.
★ 표준이 이것을 표로 보여 준다. §6.7.3.1 은 쓸 수 있는 타입 지정자 조합을 나열하는데, 한 항목에 함께 적힌 철자들은 같은 타입이라는 뜻이다.
| 표준의 항목 | 그래서 |
|---|---|
char | ★ 저 혼자 한 항목 |
signed char | ★ 저 혼자 한 항목 |
unsigned char | ★ 저 혼자 한 항목 |
int, signed, signed int | 셋이 한 항목 — 같은 타입의 세 철자 |
unsigned, unsigned int | 둘이 한 항목 |
표 26.6
문자 타입만 세 항목으로 흩어져 있다. 다른 정수 타입은 모두 「부호 있는 쪽」과 「부호 없는 쪽」 둘뿐이고, signed 를 적든 말든 같은 타입이다.
이 책이 컴파일러에게 직접 물어보았다. _Generic 은 타입이 같은 두 갈래를 적으면 컴파일 오류를 낸다 — 그래서 판정기로 쓸 수 있다.
_Generic((i), int: "a", signed int: "b") /* error: two compatible types */GCC 가 “_Generic specifies two compatible types” 라고 거절한다. 곧 컴파일러가 「둘은 같은 타입이다」라고 선언한 것이다. 반대로 char·signed char· unsigned char 는 세 갈래로 나란히 적어도 아무 말이 없고, 각각 제 갈래로 간다.
차이가 눈에 보이는 자리는 포인터다. 실측하면 이렇게 갈린다.
char c; take_signed_char(&c); /* warning: … differ in signedness */
int i; take_signed_int(&i); /* 아무 말 없다 --- 같은 타입이므로 */즉 char * 는 signed char * 와 호환되지 않는다. 부호 있는 char 를 쓰는 기계에서 값의 범위가 똑같아도 그렇다 — 앞 절에서 본 「표현이 같아도 별개 타입」(§6.2.5)이 그대로 적용된다.
흔한 오해. “그러면 int x : 3; 도 signed int x : 3; 과 같겠네”
★ 여기가 유일한 예외다. 비트필드에서는 맨 int 가 signed int 와 갈라진다 — 표준이 「타입 지정자가 int 이면 그 비트필드가 부호 있는지 없는지는 구현이 정한다」고 적어 두었기 때문이다(§6.7.3.2 각주).
그래서 비트필드에는 signed int·unsigned int 를 반드시 적는다. 맨 int 로 두면 어떤 컴파일러에서는 x = -1 이 -1 로, 다른 컴파일러에서는 7 로 읽힌다. 이 책이 쓴 기계에서는 부호 있는 쪽이었지만, 그것은 이 기계의 사정일 뿐이다.
정리하면 규칙은 이렇다. signed 는 다른 정수 타입에서는 적어도 그만 안 적어도 그만인 장식이지만, char 에서는 타입을 바꾸는 낱말이고, 비트필드에서는 안 적으면 뜻이 구현에 맡겨진다.
흔한 오해. “char 는 그냥 signed char 다”
플랫폼에 달렸다. x86 리눅스에서는 부호 있고, ARM 리눅스와 여러 임베디드 툴체인에서는 부호가 없다. <limits.h> 의 CHAR_MIN 이 0인지 SCHAR_MIN 인지로 구별할 수 있다.
이 차이가 조용히 무는 자리가 있다. 128 이상의 바이트를 char 에 담아 비교하면 부호 있는 쪽에서는 음수가 된다.
char c = 0xFF;
if (c == 0xFF) { … } /* 부호 있는 char 에서는 성립하지 않는다 */그래서 규율은 단순하다 — 글자는 char, 바이트는 unsigned char, 작은 수는 int8_t. 세 용도를 이름으로 갈라 두면 이 함정을 만나지 않는다.
플랫폼 노트. C23: bool 은 부호 없는 정수 타입이다
C23 의 §6.2.5p8 은 「bool 과 표준 부호 있는 정수 타입에 대응하는 부호 없는 타입들을 합쳐 표준 부호 없는 정수 타입이라 부른다」고 적는다. 즉 bool 은 정수 타입이고, 산술 타입이며, 스칼라다.
실측이 그것을 확인한다 — bool + 0 의 타입은 int 다(승격되었다는 뜻이다). 값은 언제나 0 아니면 1 이라는 점만 특별하다: 어떤 스칼라를 bool 에 넣어도 0이면 0, 아니면 1이 된다. C99 의 _Bool 이 C23 에서 bool 이라는 예약어를 얻은 것이고, <stdbool.h> 는 이제 있어도 그만인 헤더가 되었다(82장).
26.4 정수·실수·산술 — 겹쳐 부르는 이름들
여기부터가 이 책이 계속 써 온 어휘의 출처다. 이 이름들은 나무의 가지가 아니라 여러 가지를 묶어 부르는 낱말이다.
| 이름 | 무엇을 묶은 것인가 (§6.2.5) | 이 이름을 쓰는 자리 |
|---|---|---|
| 정수 타입(integer type) | char + 부호 있는 정수 + 부호 없는 정수 + 열거 타입 | %·<< 의 피연산자 |
| 실수 타입(real type) | 정수 타입 + 실수 부동소수점 타입 (복소수 제외) | ++·-- 의 피연산자(49장) |
| 산술 타입(arithmetic type) | 정수 타입 + 부동소수점 타입 (복소수 포함) | +·-·*·/ 의 피연산자, 승격의 대상 |
| 스칼라 타입(scalar type) | 산술 타입 + 포인터 + nullptr_t(C23) | if·while 의 조건, ! 의 피연산자 |
| 집합체 타입(aggregate type) | 배열 + 구조체 — ★공용체는 아니다 | 초기화자 목록의 규칙 |
표 26.7
49장의 연산자 계약 표에서 「피연산자: 실수 타입 또는 포인터」 같은 칸을 보게 되는데, 이제 그 칸을 정확히 읽을 수 있다.
흔한 오해. “공용체도 집합체다”
C 에서는 아니다. §6.2.5p26 은 「배열과 구조체 타입을 합쳐 집합체 타입이라 한다」고만 적는다. 공용체는 빠져 있다.
이유는 「집합체」라는 낱말이 동시에 여럿을 담는 것을 뜻하기 때문이다. 공용체는 한 번에 하나만 살아 있으므로(48장의 활성 멤버) 그 정의에 맞지 않는다.
C++ 는 다르다 — 거기서는 공용체도 조건을 갖추면 집합체(aggregate)다. 두 언어의 초기화 규칙을 비교할 때 이 차이가 드러난다.
그림 26.1 — 표준이 가른 타입의 갈래. 점선 상자는 여러 가지를 묶어 부르는 이름이다.
26.5 파생 타입 — 있는 것에서 만들어 내는 것
객체 타입과 함수 타입에서 새 타입을 지어낼 수 있고, 그렇게 지은 것을 파생 타입(derived type)이라 한다.
| 파생 | 무엇에서 무엇을 | 자세히 |
|---|---|---|
| 배열 | 원소 타입 T 에서 「T 의 배열」로 | 38장 |
| 구조체 | 여러 타입을 차례로 담아 | 46장 |
| 공용체 | 여러 타입을 겹쳐 담아 | 48장 |
| 함수 | 반환 타입 T 에서 「T 를 돌려주는 함수」로 | 24장 |
| 포인터 | 참조 타입 T 에서 「T 를 가리키는 포인터」로 | 35장 |
| 원자적 | _Atomic(T) — 조건부 기능 | 80장 |
표 26.8
이 만들기는 재귀적으로 적용된다. 「int 를 가리키는 포인터의 배열 10개」도, 「int 를 돌려주는 함수를 가리키는 포인터」도 그렇게 지어진다. 60장 「선언을 읽는 법」이 어려운 이유가 바로 이 재귀에 있고, 표준은 그중 셋 — 배열·함수·포인터 — 을 따로 묶어 파생 선언자 타입(derived declarator type)이라 부른다. 선언을 읽을 때 안쪽에서 바깥으로 풀어야 하는 것이 정확히 이 셋이다.
26.6 한정자 — 같은 타입의 한정판
타입 한정자(type qualifier)는 새 타입을 만드는 것이 아니라 한정된 판을 만든다. C23 이 정한 한정자는 넷이고, 이것이 전부다.
| 한정자 | 무엇을 약속하는가 | 어기면 | 자세히 |
|---|---|---|---|
const | 이 이름으로는 바꾸지 않는다 | 컴파일 오류(제약 위반) | 23장 |
volatile | 내가 모르는 사이에 바뀔 수 있으니 접근을 지우거나 합치지 말라 | 값이 조용히 틀려진다 | 13·76·77장 |
restrict | 이 포인터로만 이 객체에 닿는다 | ★ 미정의 동작 — 진단 없음 | 38·40장 |
_Atomic | 이 객체에 대한 접근은 쪼개지지 않는다 | 자료 경합 = 미정의 동작 | 80장 |
표 26.9
흔한 오해. “register 도 한정자 아닌가”
아니다. 그리고 이 혼동은 아주 흔하다. register 는 저장 클래스 지정자이고, static·extern·typedef 와 같은 가족이다(44장). 둘은 하는 일도 문법 자리도 다르다.
| 타입 한정자 | 저장 클래스 지정자 | |
|---|---|---|
| 무엇을 꾸미나 | 타입 | 객체(그 이름) |
| 몇 개까지 | 넷 다 겹쳐 쓸 수 있다 | ★ 원칙적으로 하나뿐 |
| 정하는 것 | 무엇을 할 수 있는가 | 수명·유효범위·연결 |
| 보기 | const volatile int | static int |
표 26.10
가르는 물음이 하나 있다. “typedef 로 뽑아낼 수 있는가.” const int 는 통째로 하나의 타입이라 typedef const int ci; 가 되지만, static 은 타입의 일부가 아니라서 typedef static int si; 는 문법 오류다. 실측하면 multiple storage classes in declaration specifiers 라고 말한다.
26.6.1 한정판은 원본과 같은 크기다
한정된 타입과 한정되지 않은 타입은 같은 크기·표현·정렬을 갖는다 — 달라지는 것은 무엇을 할 수 있는가뿐이다.
examples/ch26/qualifiers.c
/* 타입 한정자 넷을 한자리에서 확인한다.
요점은 「한정판은 같은 타입의 다른 판일 뿐, 크기도 정렬도 바뀌지 않는다」이다. */
#include <stdio.h>
#include <stdalign.h>
struct big { char a[9]; };
int main(void)
{
puts("① a qualifier changes neither size nor alignment");
printf(" int : %zu / %zu\n", sizeof(int), alignof(int));
printf(" const int : %zu / %zu\n", sizeof(const int), alignof(const int));
printf(" volatile int : %zu / %zu\n", sizeof(volatile int), alignof(volatile int));
printf(" struct big : %zu / %zu\n", sizeof(struct big), alignof(struct big));
printf(" _Atomic big : %zu / %zu <- unchanged in this implementation\n",
sizeof(_Atomic struct big), alignof(_Atomic struct big));
puts("\n② order is free, and they can be combined");
const volatile int a = 1;
volatile const int b = 2; /* 위와 완전히 같은 타입 */
printf(" const volatile int a = %d, volatile const int b = %d\n", a, b);
puts("\n③ _Atomic has two faces");
_Atomic int c = 3; /* 한정자로 쓴 꼴 */
_Atomic(int) d = 4; /* 타입 지정자로 쓴 꼴 — 같은 타입이다 */
printf(" _Atomic int c = %d, _Atomic(int) d = %d\n", c, d);
/* typedef int A[3]; _Atomic A x; <- 배열에는 붙일 수 없다: 컴파일 오류 */
puts("\n④ const only says 「not through this name」");
int x = 10;
const int *p = &x; /* p 로는 못 바꾼다 */
x = 20; /* 그러나 x 로는 바꿀 수 있다 */
printf(" changing x directly gives *p = %d <- const is a promise about the path, not the value\n", *p);
/* *p = 30; <- 오류: assignment of read-only location */
puts("\n⑤ you may add a qualifier, but you may not silently drop one");
const int ci = 7;
const int *q = &ci; /* 붙이기: 된다 */
printf(" int * -> const int * passes; the reverse warns (-Wdiscarded-qualifiers), *q = %d\n", *q);
/* int *bad = &ci; <- 경고: initialization discards 'const' qualifier */
return 0;
}
실행 결과
① a qualifier changes neither size nor alignment
int : 4 / 4
const int : 4 / 4
volatile int : 4 / 4
struct big : 9 / 1
_Atomic big : 9 / 1 <- unchanged in this implementation
② order is free, and they can be combined
const volatile int a = 1, volatile const int b = 2
③ _Atomic has two faces
_Atomic int c = 3, _Atomic(int) d = 4
④ const only says 「not through this name」
changing x directly gives *p = 20 <- const is a promise about the path, not the value
⑤ you may add a qualifier, but you may not silently drop one
int * -> const int * passes; the reverse warns (-Wdiscarded-qualifiers), *q = 7
출력 ①이 그 확인이다. const int 도 volatile int 도 4바이트이고 정렬도 같다.
★ 다만 _Atomic 에는 단서를 달아야 한다. 표준은 원자적 타입이 대응하는 비원자적 타입과 같은 크기·정렬을 가질 것을 요구하지 않는다. 구현이 잠금을 숨겨 두어야 한다면 커질 수 있다. 예제의 9바이트 구조체는 이 구현에서 그대로였지만, 그렇지 않을 수도 있다는 것이 계약이다. 그래서 원자적 타입에는 atomic_is_lock_free 라는 물음이 따로 있다(80장).
26.6.2 순서는 자유롭고, 겹쳐 쓸 수 있다
출력 ②처럼 const volatile int 와 volatile const int 는 같은 타입이다. 한정자는 타입 지정자 앞뒤 어디에 놓아도 되고, 넷을 함께 쓸 수도 있다.
같은 한정자를 두 번 적는 것도 C23 에서는 허용된다 — 한 번 적은 것과 같게 친다. typedef 를 거치면 실수로 그렇게 되기 쉽기 때문이다.
typedef const int ci;
const ci x = 1; /* const 가 두 번 --- 합법, 한 번으로 친다 */컴파일러는 친절하게도 알려 준다(GCC 는 -Wduplicate-decl-specifier). 일부러 적을 일은 없지만, 매크로와 typedef 가 겹치는 자리에서 이 규칙이 프로그램을 살린다.
26.6.3 _Atomic 만 두 얼굴을 갖는다
넷 중 _Atomic 만 한정자로도, 타입 지정자로도 쓰인다.
_Atomic int c; /* 한정자로 쓴 꼴 */
_Atomic(int) d; /* 타입 지정자로 쓴 꼴 — 같은 타입이다 */출력 ③이 둘이 같음을 보인다. 괄호 꼴이 있는 이유는 복잡한 타입을 감쌀 때 필요해서다 — _Atomic(int *) 는 「원자적인 포인터」이고, int * _Atomic 도 같은 뜻이지만 읽기가 나쁘다.
★ 그리고 _Atomic 에만 있는 제약이 하나 있다. 배열과 함수에는 붙일 수 없다. 배열 전체를 한 번에 쪼개지지 않게 만들 방법이 없기 때문이다(실측: '_Atomic'-qualified array type). _Atomic int a[3]; 는 되는데, 이것은 배열이 원자적인 것이 아니라 원소 하나하나가 원자적이라는 뜻이다.
26.6.4 한정자는 값이 아니라 통로의 성질이다
출력 ④가 이 장의 요점이다. const int *p = &x; 로 두어도 x 자체는 여전히 바꿀 수 있다. const 는 「이 값은 상수다」가 아니라 「이 이름으로는 바꾸지 않는다」 는 약속이다.
26.6.5 붙일 수는 있어도 몰래 뗄 수는 없다
출력 ⑤가 방향을 보인다. int * 를 const int * 에 넘기는 것은 합법이지만, 반대는 경고다(-Wdiscarded-qualifiers). 한정을 늘리는 방향만 조용히 허용된다.
★ 그런데 이 규칙에는 유명한 함정이 있다. char ** 를 const char ** 에 넘기는 것은 안 된다. 포인터 호환성은 한 겹 아래로 이행하지 않기 때문이다 — 자세한 논증은 60장에서 표준 조항을 따라가며 본다.
26.6.6 어디에 붙는지가 뜻을 뒤집는다
한정자를 읽을 때 가장 자주 사고가 나는 자리는 포인터다.
| 선언 | 무엇이 읽기 전용인가 |
|---|---|
const int *p | 가리키는 값 |
int const *p | 같다 — 순서만 다르다 |
int *const p | 포인터 자신 |
const int *const p | 둘 다 |
표 26.11
가르는 규칙은 한 줄이다 — 한정자는 바로 왼쪽 것을 꾸민다. 왼쪽에 아무것도 없으면 오른쪽 것을 꾸민다. 60장의 우선순위 규칙 C항이 바로 이것이고, 거기서 실행으로 갈라 본다.
문. 그러면 선언에 올 수 있는 낱말은 한정자와 저장 클래스, 이 둘뿐인가?
답. 아니다. 표준은 선언 지정자(declaration specifier) 자리에 올 수 있는 것을 다섯 갈래로 나눈다. 이 책은 각 갈래를 그 갈래가 실제로 문제되는 장에서 다룬다.
| 갈래 | 낱말들 | 모아 둔 곳 |
|---|---|---|
| 타입 지정자 | void·char·int·struct·enum·typeof … | 이 장 |
| 타입 한정자 | const·volatile·restrict·_Atomic | 이 절 |
| 저장 클래스 지정자 | auto·constexpr·extern·register·static·thread_local·typedef | 44장 |
| 함수 지정자 | inline·_Noreturn | 24장 |
| 정렬 지정자 | alignas | 47장 |
표 26.12
★ 이 다섯이 전부다. 그래서 static const volatile unsigned long x; 처럼 여러 갈래에서 하나씩 가져와 쌓는 것은 되고, 같은 갈래에서 둘을 가져오는 것은(저장 클래스처럼 하나로 제한된 갈래에서) 안 된다.
26.7 폭을 이름에 적는 타입들 — <stdint.h>
여기까지가 언어가 준 타입이고, 이제 표준 라이브러리가 이름을 붙여 준 타입이다. int 의 크기가 구현마다 다르다는 문제(다음 장)를 정면으로 푸는 도구다.
examples/ch26/stdint_kinds.c
/* <stdint.h> 의 세 갈래와 uint8_t 의 함정. */
#include <inttypes.h>
#include <limits.h>
#include <stdint.h>
#include <stdio.h>
int main(void)
{
puts("[three families - they demand different things]");
printf(" %-16s %-6s %s\n", "type", "size", "what it guarantees");
printf(" %-16s %-6zu %s\n", "uint8_t", sizeof(uint8_t),
"exactly 8 bits, no padding (optional)");
printf(" %-16s %-6zu %s\n", "uint_least8_t", sizeof(uint_least8_t),
"smallest type with at least 8 bits (required)");
printf(" %-16s %-6zu %s\n", "uint_fast8_t", sizeof(uint_fast8_t),
"usually the fastest with at least 8 bits (required)");
printf(" %-16s %-6zu %s\n", "uint_least32_t", sizeof(uint_least32_t), "at least 32 bits");
printf(" %-16s %-6zu %s\n", "uint_fast32_t", sizeof(uint_fast32_t), "at least 32 bits, speed first");
printf(" %-16s %-6zu %s\n", "uintmax_t", sizeof(uintmax_t), "the widest integer (required)");
printf(" %-16s %-6zu %s\n", "uintptr_t", sizeof(uintptr_t), "round-trips a void* (optional)");
puts("\n[trap 1: uint8_t is usually unsigned char, so it leaks out as a character]");
uint8_t age = 65;
printf(" as %%u: %u\n", age);
printf(" as %%c: %c <- you get 'A', not 65\n", age);
printf(" putchar(age) is the same trap\n");
puts("\n[trap 2: arithmetic promotes to int - this is not 8-bit arithmetic]");
uint8_t a = 200, b = 100;
printf(" a + b = %d <- computed as int, so 300 (it did not wrap)\n", a + b);
printf(" (uint8_t)(a+b) = %u <- it wraps only when stored back\n", (uint8_t)(a + b));
printf(" sizeof(a + b) = %zu <- the result is 4 bytes\n", sizeof(a + b));
puts("\n[trap 3: they are only aliases, so the format macros must follow]");
printf(" with PRIu8: %" PRIu8 "\n", age);
printf(" UINT8_C(200) is %u, UINT8_MAX is %u\n", UINT8_C(200), UINT8_MAX);
puts("\n[which one, when]");
puts(" protocol, file format, register -> exact width (uint32_t)");
puts(" portability first, width at least -> minimum width (uint_least16_t)");
puts(" loop counter, local arithmetic -> fastest width (uint_fast32_t)");
puts(" size, index, byte count -> size_t (<stddef.h>)");
return 0;
}
실행 결과
[three families - they demand different things]
type size what it guarantees
uint8_t 1 exactly 8 bits, no padding (optional)
uint_least8_t 1 smallest type with at least 8 bits (required)
uint_fast8_t 1 usually the fastest with at least 8 bits (required)
uint_least32_t 4 at least 32 bits
uint_fast32_t 8 at least 32 bits, speed first
uintmax_t 8 the widest integer (required)
uintptr_t 8 round-trips a void* (optional)
[trap 1: uint8_t is usually unsigned char, so it leaks out as a character]
as %u: 65
as %c: A <- you get 'A', not 65
putchar(age) is the same trap
[trap 2: arithmetic promotes to int - this is not 8-bit arithmetic]
a + b = 300 <- computed as int, so 300 (it did not wrap)
(uint8_t)(a+b) = 44 <- it wraps only when stored back
sizeof(a + b) = 4 <- the result is 4 bytes
[trap 3: they are only aliases, so the format macros must follow]
with PRIu8: 65
UINT8_C(200) is 200, UINT8_MAX is 255
[which one, when]
protocol, file format, register -> exact width (uint32_t)
portability first, width at least -> minimum width (uint_least16_t)
loop counter, local arithmetic -> fastest width (uint_fast32_t)
size, index, byte count -> size_t (<stddef.h>)
26.7.1 세 갈래를 가르는 기준은 「무엇을 요구하는가」다
| 갈래 | 이름 | 무엇을 보장하는가 | 있는가 |
|---|---|---|---|
| 정확 폭 | int8_t uint32_t … | 정확히 N 비트, 패딩 비트 없음, 2의 보수 | 선택 사항 — 그런 타입이 있는 구현만 정의한다 |
| 최소 폭 | int_least8_t uint_least32_t … | N 비트 이상 중 가장 작은 것 | 8·16·32·64 는 필수 |
| 가장 빠른 폭 | int_fast8_t uint_fast32_t … | N 비트 이상 중 대개 가장 빠른 것 | 8·16·32·64 는 필수 |
| 포인터 담기 | intptr_t uintptr_t | void * 를 담았다 되돌리면 같은 포인터 | 선택 사항(35장) |
| 가장 넓은 | intmax_t uintmax_t | 어떤 정수 타입의 값도 담는다 | 필수 |
표 26.13
실측에서 이 차이가 드러난다 — 이 기계에서 uint_fast32_t 는 8바이트였다. 32비트면 충분한데 64비트를 쓴 것이고, 그것이 「빠른」의 뜻이다(레지스터 폭에 맞는 쪽이 빠르다). 크기를 아끼려는 타입이 아니다.
문. 「선택 사항」이라니 — uint8_t 가 없는 기계가 정말 있는가?
답. 드물지만 있다. 그리고 「선택 사항」이라는 말은 사실 절반만 맞다 — 표준의 조건이 더 정확하다.
“ 구현이 어떤 폭과 패딩 비트 없음을 갖춘 표준 정수 타입이나 확장 정수 타입을 제공한다면, 그에 대응하는 typedef 이름을 정의해야 한다.(§7.22.1.1) ”
즉 구현이 마음대로 빼는 것이 아니라, 그런 타입이 기계에 없으면 정의할 수가 없는 것이다. uint8_t 가 그 예다 — 일부 DSP 는 char 가 16비트라 「정확히 8비트」인 타입 자체가 없고, 그래서 uint8_t 를 정의하지 않는다. CHAR_BIT 가 8이 아닌 기계에서는 8의 배수가 아닌 폭이 줄줄이 빠진다.
★ 폭이 넓을수록 사정이 낫다. uint32_t 가 없는 기계는 훨씬 드물다 — 워드가 36비트인 옛 대형기처럼 어떤 정수 타입도 정확히 32비트가 아닌 기계라야 한다. 그러니 「없을까 봐」 걱정한다면 그 걱정은 대개 uint8_t·uint16_t 쪽이다.
실무의 판단은 이렇다. 데스크톱·서버·주류 임베디드만 상대한다면 정확 폭 타입을 마음 놓고 쓴다(12장의 사다리에서 1~2단). 모든 C 구현을 상대하는 라이브러리를 쓴다면 최소 폭 타입을 쓰고, 정확 폭이 꼭 필요한 자리는 static_assert(sizeof(uint8_t) == 1) 처럼 빌드가 말하게 해 둔다(사다리 3단).
26.7.2 uint8_t 의 함정 셋
가장 많이 쓰이면서 가장 많이 다치는 타입이라 따로 짚는다.
반례. ① 숫자를 넣었는데 글자가 나온다
uint8_t age = 65;
printf("%c\n", age); /* 'A' 가 나온다 */
putchar(age); /* 같은 함정 */uint8_t 는 대개 unsigned char 의 별명이라, 문자를 받는 자리에 그대로 들어간다. 컴파일러는 타입이 맞으므로 아무 말도 하지 않는다.
uint8_t 는 「작은 수」가 아니라 「바이트」로 쓰는 것이 안전하다. 사람에게 보여 줄 작은 수라면 int 나 int16_t 를 쓰고, 굳이 인쇄해야 한다면 %u(승격되므로 unsigned 로 받는다)나 PRIu8 을 쓴다.
반례. ② 8비트 산술이라고 생각하기
uint8_t a = 200, b = 100;
a + b /* 44 가 아니라 300 이다 */실측이 그것을 보인다. uint8_t 는 int 보다 좁으므로 산술 전에 int 로 승격된다(다음다음 장). 즉 계산은 32비트에서 일어나고, 감아 돌게 하려면 다시 담아야 한다 — (uint8_t)(a + b) 가 44 다.
이것은 결함이 아니라 오히려 안전장치다. 좁은 타입끼리 곱해도 중간 결과가 잘리지 않는다. 다만 「8비트 변수를 썼으니 8비트로 계산되겠지」라는 기대만은 버려야 한다.
반례. ③ 새 타입이라고 생각하기
typedef 는 별명을 지을 뿐 새 타입을 만들지 않는다(60장). 그래서 uint8_t 와 unsigned char 는 같은 타입이고, _Generic 도 오버로딩도 둘을 구별하지 못한다. 실측의 첫 출력이 그 증거다.
같은 이유로 이런 코드는 컴파일러가 잡아 주지 않는다.
void send(uint8_t port, uint8_t value);
send(value, port); /* 순서를 바꿔 넣어도 조용하다 */타입으로 구별하고 싶다면 구조체로 감싸는 것이 C 에서 쓰는 방법이다 — struct port { uint8_t v; }; 처럼. 대가는 문법이 번거로워지는 것이고, 얻는 것은 컴파일러의 검사다.
26.7.3 그래서 무엇을 쓰는가
| 이런 자리 | 이것을 쓴다 | 왜 |
|---|---|---|
| 프로토콜·파일 형식·하드웨어 레지스터 | 정확 폭 uint32_t | 바이트 수가 계약이다(48장) |
| 모든 구현을 상대하는 이식성 코드 | 최소 폭 uint_least16_t | 정확 폭은 선택 사항이다 |
| 반복 카운터·지역 계산 | 가장 빠른 폭 uint_fast32_t, 또는 그냥 int | 폭보다 속도가 값진 자리 |
| 크기·인덱스·바이트 수 | size_t(<stddef.h>) | sizeof 의 타입이고 배열을 다 덮는다 |
| 두 포인터의 차 | ptrdiff_t(<stddef.h>) | 부호가 필요하다(38장) |
| 사람에게 보일 작은 수 | int | 승격되면 어차피 int 다 |
표 26.14
마지막 줄이 실무의 기본값이라는 것을 강조해 둔다. 특별한 이유가 없으면 int 다. 폭을 이름에 적는 타입은 폭이 계약인 자리에 쓰는 도구이지, 아껴 쓰면 좋은 것이 아니다.
실제 사례. size_t 는 어디에 사는가
자주 헷갈리는 자리라 적어 둔다. size_t 와 ptrdiff_t 는 <stdint.h> 가 아니라 <stddef.h> 소속이다. <stdint.h> 는 폭을 이름에 적은 타입들의 집이고, <stddef.h> 는 언어가 쓰는 타입들(size_t·ptrdiff_t·nullptr_t·max_align_t)의 집이다.
실무에서는 <stdio.h> 같은 다른 헤더가 size_t 를 함께 끌어와 주는 일이 많아 이 구별이 흐려지는데, 그 헤더가 언젠가 바뀌면 깨진다. 쓰는 타입이 정의된 헤더를 직접 포함하는 것이 규율이다(55장의 이름 이야기와 같은 결이다).
26.8 요약 — 표준의 낱말과 이 책의 장
| 표준의 낱말 | 무엇인가 | 정면으로 다루는 장 |
|---|---|---|
| object type / function type | 값을 담는 것 / 일하는 것 | 이 장, 24·59장 |
| complete / incomplete type | 크기를 아는가 | 이 장, 47장 |
| basic types | char + 정수 + 부동소수점 | 이 장, 27·49장 |
| character types | char·signed char·unsigned char 셋 | 이 장, 9·42장 |
| integer types | char + 정수 + 열거 | 27장 |
| real types | 정수 + 실수 부동소수점 | 49장 |
| arithmetic types | 정수 + 부동소수점 | 29장(승격) |
| derived types | 배열·구조체·공용체·함수·포인터·원자적 | 35~38·44~48장 |
| scalar types | 산술 + 포인터 + nullptr_t | 30·36장 |
| aggregate types | 배열 + 구조체 | 38·46장 |
| qualified types | const·volatile·restrict·_Atomic — 넷 | 이 장, 23·38·80장 |
| storage-class specifiers | static·extern·register … 일곱 | 44장 |
| function specifiers | inline·_Noreturn | 24장 |
표 26.15
갈래를 폈다. 다음 장부터 이 갈래의 한 칸씩을 파고든다 — 먼저 정수다. 이 장에서 「부호 있는 정수 타입과 부호 없는 정수 타입」이라 한 마디로 지나간 그 가족의 속을, 범위와 표현과 사고까지 함께 본다.