Proven C BookEnglish GitHub

66 잡동사니 서랍 — <stdlib.h>

먼저 알아야 할 것

61장 표준 라이브러리의 지형 · 표준 라이브러리의 지형
45장 동적 메모리 · 할당과 반납

돌아보기

25장에서 입력을 fgets로 읽고 sscanf로 해석했고, 61장에서는 sscanf가 “어디에서 왜 실패했는지” 말해 주지 않는다고 했다. 그러면 문자열 하나를 정수로 바꾸는 가장 정확한 방법은 무엇인가?

답. strtol 계열이다. 이 함수는 세 가지를 한꺼번에 알려 준다 — 변환한 값, 어디서 멈췄는지(끝 포인터), 그리고 범위를 넘었는지(errnoERANGE). atoi는 이 중 아무것도 알려 주지 않는다. 이름이 짧아 입문서에 자주 등장하지만, 실무에서는 쓰지 않는 함수다.

이 장의 필요성과 맥락

서랍에 잡동사니가 들어 있듯 이 헤더에도 공통점 없는 것들이 모여 있다. 그래도 이 자리에 두는 데는 이유가 있다 — 45장의 동적 할당과 51장의 실패 알리기가 둘 다 여기 모여 있어서, 앞에서 배운 두 실을 한 번에 당겨 볼 수 있기 때문이다.

이 장이 끝나면

이름 그대로 “표준 라이브러리”라는 서랍이다. 문자열을 수로 바꾸는 함수, 난수, 동적 할당(dynamic allocation), 프로그램 종료, 정렬과 이진 탐색이 한데 들어 있다. 공통점이 없는 대신 함정은 많다 — 특히 실패를 알릴 방법이 없는 변환 함수종료 함수들의 미묘한 차이가 이 장의 두 축이다.

이 장에서 답할 질문

  1. 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

출력을 나란히 놓으면 차이가 분명하다.

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는 스트림을 비워 주지만 _Exitabort는 그러지 않는다 — 64장에서 본 “출력이 사라지는” 사고가 여기서 난다. abort로 죽는 프로그램의 마지막 로그가 안 보이는 이유이기도 하다.

atexit로 등록한 함수는 등록의 역순으로 불리고, 표준은 최소 32개까지 등록을 보장한다. 등록 함수 안에서 다시 exit를 부르는 것은 계약 밖이다.

66.4 정렬과 탐색 — qsort·bsearch

62장에서 예고한 그 함수다. 계약을 정확히 적으면 이렇다.

66.5 난수 — rand의 한계

rand는 0부터 RAND_MAX까지의 수를 돌려준다. RAND_MAX는 최소 32767만 보장되므로, 그보다 큰 범위가 필요하면 조합해야 한다.

반례. rand() % n으로 범위 만들기

int dice = rand() % 6 + 1;      /* 값이 고르지 않다 */

RAND_MAX + 1n의 배수가 아니면 앞쪽 값이 더 자주 나온다. 범위가 작으면 치우침도 작지만, 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 + 끝 포인터 + errnoatoi
문자열 → 실수strtod(로케일 주의)atof
배열 할당calloc(k, n)malloc(k * n)
크기 바꾸기임시 변수 경유 realloc원본에 바로 대입
정상 종료return/exit_Exit(버퍼 손실)
정렬qsort + 전순서 비교자뺄셈 비교자, 안정성 가정
난수거부 표집, 용도에 맞는 생성기rand() % n, 비밀에 사용
외부 명령플랫폼 API로 인자 배열system에 입력 이어 붙이기

표 66.3

서랍을 정리했다. 다음 장은 한 글자를 다루는 함수들 — 그리고 그 함수들이 로케일이라는 전역 상태에 묶여 있다는 사실이다.