66 잡동사니 서랍 — <stdlib.h>
먼저 알아야 할 것
돌아보기
25장에서 입력을 fgets로 읽고 sscanf로 해석했고, 61장에서는 sscanf가 “어디에서 왜 실패했는지” 말해 주지 않는다고 했다. 그러면 문자열 하나를 정수로 바꾸는 가장 정확한 방법은 무엇인가?
답. strtol 계열이다. 이 함수는 세 가지를 한꺼번에 알려 준다 — 변환한 값, 어디서 멈췄는지(끝 포인터), 그리고 범위를 넘었는지(errno가 ERANGE). atoi는 이 중 아무것도 알려 주지 않는다. 이름이 짧아 입문서에 자주 등장하지만, 실무에서는 쓰지 않는 함수다.
이 장의 필요성과 맥락
이 장이 끝나면
이 장에서 답할 질문
qsort는 왜 비교 함수를void *두 개로 받는가 — 타입을 알면 더 빠를 텐데?
66.1 변환 — atoi를 버리는 이유
examples/ch66/convert.c
#include <stdio.h>
#include <stdlib.h>
#include <errno.h>
#include <limits.h>
#include <string.h>
/* 문자열을 수로 바꾸는 세 가지 방법과 그 차이 */
static void try_atoi(const char *s)
{
printf(" atoi(\"%s\") = %d\n", s, atoi(s)); /* 실패를 알릴 방법이 없다 */
}
static void try_strtol(const char *s)
{
errno = 0;
char *end;
long v = strtol(s, &end, 10);
if (end == s) printf(" strtol(\"%s\"): not a number\n", s);
else if (errno == ERANGE) printf(" strtol(\"%s\"): out of range (ERANGE)\n", s);
else if (*end != '\0') printf(" strtol(\"%s\"): %ld, characters left [%s]\n", s, v, end);
else printf(" strtol(\"%s\"): %ld (clean)\n", s, v);
}
int main(void)
{
const char *cases[] = { "42", "abc", "42abc", "99999999999999999999", " 7", "" };
printf("atoi - it cannot tell failure from 0:\n");
for (size_t i = 0; i < sizeof cases / sizeof cases[0]; i++) try_atoi(cases[i]);
printf("strtol - it tells you what went wrong:\n");
for (size_t i = 0; i < sizeof cases / sizeof cases[0]; i++) try_strtol(cases[i]);
printf("LONG_MAX = %ld\n", LONG_MAX);
return 0;
}
실행 결과
atoi - it cannot tell failure from 0:
atoi("42") = 42
atoi("abc") = 0
atoi("42abc") = 42
atoi("99999999999999999999") = -1
atoi(" 7") = 7
atoi("") = 0
strtol - it tells you what went wrong:
strtol("42"): 42 (clean)
strtol("abc"): not a number
strtol("42abc"): 42, characters left [abc]
strtol("99999999999999999999"): out of range (ERANGE)
strtol(" 7"): 7 (clean)
strtol(""): not a number
LONG_MAX = 9223372036854775807
출력을 나란히 놓으면 차이가 분명하다.
atoi("abc")는 0이다. 그런데atoi("0")도 0이다 — 실패와 성공을 구별할 수 없다.atoi("42abc")는 42다. 뒤에 붙은 쓰레기를 조용히 무시한다.atoi("99999999999999999999")는 −1이 나왔다. 표준은 이 경우 정의되지 않은 동작이라고만 말한다 — 우연히 −1이 나왔을 뿐, 어떤 값이든 나올 수 있고 무엇도 보장되지 않는다.
strtol은 같은 입력에 대해 “숫자가 아님”, “남은 글자 있음”, “범위 밖”을 각각 구별해 냈다. 검사 코드가 길어 보이지만, 그 길이가 바로 바깥에서 온 문자열을 다루는 일의 실제 복잡도다.
반례. atoi로 사용자 입력 읽기
int port = atoi(argv[1]); /* "0"과 "잘못된 입력"이 같은 값 */고쳐 쓰면 이렇다.
errno = 0;
char *end;
long v = strtol(argv[1], &end, 10);
if (end == argv[1] || *end != '\0') { /* 숫자가 아니다 */ }
else if (errno == ERANGE || v < 1 || v > 65535) { /* 범위 밖 */ }
else port = (int)v;errno를 호출 직전에 0으로 놓는 것이 규약이다(75장). 그러지 않으면 이전 호출이 남긴 값을 읽게 된다.
실수 변환도 같다. atof는 실패를 알리지 못하고, strtod는 끝 포인터와 ERANGE를 준다. 게다가 strtod는 로케일에 따라 소수점을 .이 아니라 ,로 읽을 수 있다(67장) — 데이터 형식을 파싱할 때 반드시 기억할 점이다.
문. qsort는 왜 비교 함수를 void * 두 개로 받는가 — 타입을 알면 더 빠를 텐데?
답. 표준 라이브러리가 어떤 타입의 배열이든 정렬해야 하기 때문이다. C에는 제네릭이 없으니 타입을 지우는 통로가 void *뿐이고(35장), 그 대가로 비교자 안에서 매번 캐스트하고 역참조해야 한다. 대가는 성능만이 아니다 — 타입이 지워진 자리에서 실수하면 컴파일러가 잡아 주지 못한다. 원소 크기를 틀리게 넘기거나, 비교자가 int*를 받아야 하는데 int로 받으면 조용히 무너진다. 92장의 proven_array_sort가 매크로로 타입을 붙잡는 이유이고, 85장이 “검사되지 않는 콜백”을 다섯 버그의 하나로 꼽는 이유이기도 하다.
66.2 동적 할당 — 네 함수와 그 계약
45장에서 배운 것을 함수 단위로 정리한다.
| 함수 | 하는 일 | 계약과 함정 |
|---|---|---|
malloc(n) | n바이트 할당 | 내용은 미정. 실패 시 널 |
calloc(k, n) | k×n바이트 할당 + 0으로 채움 | ★ 곱셈 넘침을 구현이 검사한다 |
realloc(p, n) | 크기 바꾸기 | ★ 실패 시 원본 유지 — 반환값을 원본에 바로 대입하면 누수 |
free(p) | 해제 | 널은 안전. 두 번 해제는 계약 밖 |
aligned_alloc(a, n) | 정렬된 할당(C11) | n이 a의 배수여야 한다 |
표 66.1
calloc의 곱셈 검사가 유용한 지점이다. malloc(k * n)은 65장에서 본 대로 곱이 감길 수 있지만, calloc(k, n)은 구현이 넘침을 검사해 널을 돌려주도록 표준이 요구한다. 크기를 곱해야 한다면 calloc이 더 안전한 선택이다.
반례. realloc의 반환값을 원본에 바로 대입하기
buf = realloc(buf, new_size); /* 실패하면 원본 주소를 잃는다 → 누수 */
if (!buf) return -1;올바른 관용구는 임시 변수를 거치는 것이다.
char *tmp = realloc(buf, new_size);
if (!tmp) { /* buf 는 여전히 유효하다 — 정리하거나 계속 쓸 수 있다 */ return -1; }
buf = tmp;64장의 줄 읽기 예제가 이 관용구를 쓴 이유다.
realloc에는 특이한 규칙이 둘 더 있다. realloc(NULL, n)은 malloc(n)과 같고, realloc(p, 0)은 쓰지 않는다. 후자의 지위가 판마다 달라졌기 때문이다 — C17까지는 구현 정의이면서 폐기 예정으로 표시돼 있었고, C23에서는 아예 정의되지 않은 동작이 됐다(제안 N2464). 구현마다 답이 너무 갈려서 표준이 정하기를 포기한 자리다.
이 변화가 실무에 남기는 것은 한 줄이다 — 해제하려면 free(p)를 쓴다. 크기가 0이 될 수 있는 재할당 코드라면 n == 0을 먼저 걸러 내야 한다.
proven_err_t resize(char *buf, size_t n) {
if (n == 0) { free(*buf); *buf = nullptr; return OK; } /* 0을 넘기지 않는다 */
char *tmp = realloc(*buf, n);
...
}66.3 종료 — 네 가지 방법의 차이
| 방법 | 정리 작업 | 쓰는 자리 |
|---|---|---|
return (main에서) | exit와 같다 | 정상 종료 |
exit(status) | atexit 실행, 스트림 flush·닫기 | 정상 종료(깊은 곳에서) |
quick_exit(status) | at_quick_exit만 실행, flush 없음 | 빠른 종료(C11) |
_Exit(status) | 아무것도 하지 않음 | 자식 프로세스 등 특수한 자리 |
abort() | 정리 없음, 비정상 종료 신호 | 복구 불가능한 오류 |
표 66.2
핵심은 버퍼다. exit는 스트림을 비워 주지만 _Exit와 abort는 그러지 않는다 — 64장에서 본 “출력이 사라지는” 사고가 여기서 난다. abort로 죽는 프로그램의 마지막 로그가 안 보이는 이유이기도 하다.
atexit로 등록한 함수는 등록의 역순으로 불리고, 표준은 최소 32개까지 등록을 보장한다. 등록 함수 안에서 다시 exit를 부르는 것은 계약 밖이다.
66.4 정렬과 탐색 — qsort·bsearch
62장에서 예고한 그 함수다. 계약을 정확히 적으면 이렇다.
- 비교자는
int cmp(const void *a, const void *b)이고, 음수·0·양수를 돌려준다. 뺄셈으로 만들면 넘칠 수 있다 —return *x - *y;는 큰 값에서 틀린다.return (*x > *y) - (*x < *y);가 안전한 관용구다. - 비교자는 전순서여야 한다(85장). 일관되지 않으면 결과가 뒤죽박죽인 정도가 아니라 배열 밖을 침범할 수 있다.
qsort는 안정 정렬이 아니다. 같은 값의 상대 순서가 보존되지 않는다. 필요하면 비교자에 원래 인덱스를 얹어 동점을 가른다.- 최악 성능은 구현이 정한다. 표준은 아무 보장도 하지 않는다 — 62장의 복잡도 공격이 가능했던 이유다.
bsearch는 정렬된 배열을 전제한다. 정렬되지 않았으면 결과는 의미 없다.
66.5 난수 — rand의 한계
rand는 0부터 RAND_MAX까지의 수를 돌려준다. RAND_MAX는 최소 32767만 보장되므로, 그보다 큰 범위가 필요하면 조합해야 한다.
반례. rand() % n으로 범위 만들기
int dice = rand() % 6 + 1; /* 값이 고르지 않다 */RAND_MAX + 1이 n의 배수가 아니면 앞쪽 값이 더 자주 나온다. 범위가 작으면 치우침도 작지만, n이 커질수록 눈에 띈다. 게다가 옛 구현 중에는 하위 비트의 품질이 나쁜 것이 있어 % 2가 번갈아 나오기도 했다.
고른 분포가 필요하면 거부 표집(rejection sampling)을 쓴다 — 범위를 넘는 값이 나오면 버리고 다시 뽑는다. 그리고 비밀에는 절대 쓰지 않는다 (제12부의 난수 이야기).
srand로 씨앗을 주지 않으면 srand(1)과 같다 — 매번 같은 수열이다. srand(time(NULL))이 흔한 관용구이지만, 같은 초에 시작한 두 프로세스는 같은 수열을 얻는다.
66.6 환경과 프로세스
getenv는 환경 변수를 돌려주지만, 그 문자열은 수정하면 안 되고 다음 setenv류 호출 뒤에는 유효하지 않을 수 있다. 값이 필요하면 복사해 둔다.
system은 셸을 띄워 명령을 실행한다. 사용자 입력이 그 문자열에 섞이면 명령 주입이 된다 — 웹 애플리케이션의 고전적 취약점과 같은 부류다. 표준 안에서는 대안이 없고, 플랫폼 API(posix_spawn, CreateProcess)를 써서 인자를 배열로 넘기는 것이 정답이다.
실제 사례. qsort 비교자의 뺄셈이 만든 실제 버그
return a - b; 꼴의 비교자는 정렬 코드에서 가장 흔한 실수다. 값이 INT_MIN에 가까우면 뺄셈이 넘쳐 부호가 뒤집히고, 정렬은 조용히 틀린 결과를 낸다 — 7장에서 배운 부호 있는 오버플로가 계약 밖이므로 컴파일러의 최적화에 따라 증상이 달라지기까지 한다.
이 무늬는 커널·데이터베이스·게임 엔진 가릴 것 없이 반복해서 보고됐고, 정적 분석 도구들이 별도 규칙으로 잡아 줄 만큼 흔하다. 고치는 법은 한 줄이다 — 빼지 말고 비교한다.
복습 정리
<stdlib.h> 요약.
| 하고 싶은 일 | 쓸 것 | 피할 것 |
|---|---|---|
| 문자열 → 정수 | strtol + 끝 포인터 + errno | atoi |
| 문자열 → 실수 | strtod(로케일 주의) | atof |
| 배열 할당 | calloc(k, n) | malloc(k * n) |
| 크기 바꾸기 | 임시 변수 경유 realloc | 원본에 바로 대입 |
| 정상 종료 | return/exit | _Exit(버퍼 손실) |
| 정렬 | qsort + 전순서 비교자 | 뺄셈 비교자, 안정성 가정 |
| 난수 | 거부 표집, 용도에 맞는 생성기 | rand() % n, 비밀에 사용 |
| 외부 명령 | 플랫폼 API로 인자 배열 | system에 입력 이어 붙이기 |
표 66.3
서랍을 정리했다. 다음 장은 한 글자를 다루는 함수들 — 그리고 그 함수들이 로케일이라는 전역 상태에 묶여 있다는 사실이다.