malloc은 한 가지 질문에 답합니다. 이만큼의 바이트를 달라는 것이죠. <stdlib.h>에 있는 두 동반자는 그다음에 오는 질문들에 답합니다. 이만큼의 바이트를, 깨끗이 비워서 달라(calloc), 그리고 이미 가진 블록이 더 커져야 한다(realloc)입니다.
calloc: 개수, 크기, 그리고 0
void *calloc(size_t count, size_t size);
인자가 하나가 아니라 둘이고, 결과는 0 바이트로 채워집니다.
전형적인 쓰임새는 이렇습니다. 모든 칸이 0에서 시작해야 하는 히스토그램이나 집계 배열이죠. malloc이라면 반복문이 필요하지만, calloc은 할당의 일부로 그 일을 해 주며 종종 공짜입니다. 운영체제가 이미 0으로 비워진 새 페이지를 내주기 때문입니다.
오버플로라는 근거
인자를 둘로 나눈 것은 겉치레가 아닙니다. 파일에서 읽어 온 개수를 생각해 보세요.
size_t n = huge_value_from_input;
int *a = malloc(n * sizeof *a); /* 곱셈 결과가 넘칠 수 있음 */
int *b = calloc(n, sizeof *b); /* 오버플로를 반드시 감지해야 함 */
n * sizeof *a가 size_t가 담을 수 있는 범위를 넘으면 값이 작은 수로 되돌아가고(wrap), malloc은 아주 작은 블록으로 성공하며, 그 뒤의 모든 쓰기가 끝을 한참 넘어갑니다. calloc은 표준상 이런 경우 실패하고 NULL을 반환해야 합니다. 크기가 프로그램 바깥에서 올 때 이 검사는 갖출 만한 가치가 있습니다.
"0으로 채운다"의 진짜 의미
calloc은 0 바이트를 씁니다. 정수와 문자에서는 그것이 값 0이고, 바로 여러분이 원하는 바입니다. 포인터와 부동소수점 값의 경우, 모든 비트가 0인 것은 주류 플랫폼 전부에서 널 포인터와 0.0이지만 C 표준이 그렇다고 보장하지는 않습니다. 엄격한 이식성이 필요한 코드는 그런 값들을 명시적으로 대입합니다.
calloc 대 malloc + memset
int *a = calloc(n, sizeof *a);
int *b = malloc(n * sizeof *b);
if (b != NULL) {
memset(b, 0, n * sizeof *b);
}
두 방식의 결과는 같습니다. calloc을 선호하세요. 한 줄이고, 오버플로 검사를 해 주며, 큰 블록에서는 메모리를 아예 건드리지 않을 수도 있습니다. 어차피 모든 바이트를 덮어쓸 참이라면 순수한 malloc을 쓰세요. 곧바로 채울 1메가바이트를 0으로 미는 것은 완전한 낭비입니다.
realloc: 크기 바꾸기
void *realloc(void *p, size_t newsize);
realloc은 newsize 바이트짜리 블록을 반환하며, 그 내용은 두 크기 중 작은 쪽까지 예전 블록과 같습니다. 블록을 있던 자리에서 늘릴 수도 있고, 새로 할당해 복사한 뒤 예전 것을 해제할 수도 있습니다. 어느 쪽인지 알 방법은 없으므로, 이후에 쓸 수 있는 포인터는 반환받은 것뿐입니다.
tmp 포인터 관용구
tmp 주변의 세 줄이야말로 이 함수가 악명을 얻은 이유 전부입니다. 솔깃한 축약형은 망가져 있습니다.
/* 버그: realloc이 실패하면 원래 블록이 누수됩니다 */
p = realloc(p, newsize);
if (p == NULL) {
return;
}
realloc이 실패하면 NULL을 반환하고 원래 블록은 할당된 채 그대로 둡니다. 결과를 곧바로 p에 대입하면 그 블록을 가리키는 유일한 포인터를 덮어쓰게 되어 영영 해제할 수 없습니다. 누수이며, 하필 메모리가 이미 부족한 바로 그 순간에 일어나는 누수입니다. tmp에 대입하고, tmp를 검사한 뒤, 확정하세요.
void *tmp = realloc(p, newsize);
if (tmp == NULL) {
/* p는 여전히 유효함; 호출자에게 맞는 방식으로 실패를 처리하세요 */
return 0;
}
p = tmp;
알아 둘 만한 동작이 둘 더 있습니다. realloc(NULL, n)은 malloc(n)과 정확히 똑같이 동작하므로, 성장 함수가 첫 호출을 특별 취급 없이 처리할 수 있습니다. 그리고 크기를 늘리면 새로 생긴 바이트는 초기화되지 않습니다. 보존되는 것은 예전 내용뿐입니다.
배열 늘리기: 표준 패턴
거의 모든 C 컨테이너가 쓰는 패턴이 여기 있습니다. 길이, 용량, 그리고 둘이 만나면 두 배로 늘리기입니다.
여기서 무게를 지탱하는 설계 포인트는 둘입니다.
하나씩 더하지 말고 두 배로. 고정된 양만큼 늘리면 push할 때마다 배열 전체를 복사하게 되어, n개를 채우는 데 대략 n²번의 바이트 복사가 듭니다. 두 배로 늘리면 복사가 충분히 드물어져서 push 하나의 평균 비용이 상수 시간이 됩니다. cap == 0 ? 4 : cap * 2라는 시작은 별도 분기 없이 첫 push를 처리해 줍니다. realloc(NULL, n)이 곧 malloc이니까요.
구조체가 할당 하나를 소유합니다. v.data는 그 구조체를 소유한 쪽이 정확히 한 번만 해제합니다. 그 소유권 규칙을 글로 적어 두는 것(주석으로든, 모든 vec_init을 vec_free와 짝지어 두는 방식으로든)이 커지는 컨테이너가 함수들 사이를 오가면서도 누수되지 않게 지켜 줍니다.
크기 줄이기
더 작은 크기로 realloc하면 블록이 잘립니다. 크기를 넉넉히 잡은 버퍼에 양을 알 수 없는 데이터를 읽어 들인 뒤에 유용합니다.
축소 실패는 프로그램을 중단할 만한 오류가 아닙니다. 예전의 더 큰 블록이 여전히 유효하고 여전히 올바르니까요. 실패를 무시하는 것이 옳은 판단인 유일한 경우입니다. 다만 그래도 tmp를 거친다는 점에 주목하세요. 살아 있는 포인터를 덮어쓰지 말라는 규칙은 달라지지 않습니다.
무엇을 골라야 할까
| 원하는 것 | 사용할 함수 |
|---|---|
| 바이트만 필요하고 내용은 상관없음 | malloc(n * sizeof *p) |
| 항목 개수만큼, 0으로 비워서 | calloc(n, sizeof *p) |
| 신뢰할 수 없는 입력에서 온 개수 | 오버플로 검사를 위해 calloc |
| 가진 블록을 더 크게 또는 더 작게 | tmp 포인터를 거친 realloc |
| 성장 반복문의 첫 할당 | realloc(NULL, n) - 특별 처리 불필요 |
넷 모두 같은 free로, 정확히 한 번 해제됩니다.
흔한 실수
p = realloc(p, n)- 실패 시 누수됩니다. 항상tmp를 거치세요.- 블록 내부를 가리키던 예전 포인터를 계속 쓰기.
realloc이후에는 데이터가 옮겨졌을 수 있으므로, 저장해 둔 내부 포인터나 인덱스로 계산해 둔 주소는 모두 낡은 값입니다. 이동해도 인덱스는 살아남지만 포인터는 그렇지 않습니다. - 새 바이트가 0이라고 가정하기. 0으로 채우는 것은
calloc뿐입니다.realloc은 늘어난 영역을 초기화하지 않습니다. calloc(n * size, 1)- 인자를 둘로 나눈 형태가 존재하는 이유인 오버플로 검사를 스스로 내던지는 셈입니다.realloc성공 후에 예전 포인터를 해제하기.realloc이 이미 해제했으므로 두 번째free는 이중 해제입니다.
자주 묻는 질문
C에서 calloc과 malloc의 차이는 무엇인가요?
두 가지입니다. calloc(n, size)는 개수와 요소 크기를 따로 받아서 둘을 곱했을 때 오버플로가 나지 않는지 검사하지만, malloc(n * size)는 조용히 값이 넘칠 수 있습니다. 그리고 calloc은 블록을 0으로 채우는 반면, malloc은 그 자리에 있던 값을 그대로 둡니다.
C에서 realloc은 어떻게 동작하나요?
realloc(p, newsize)는 새 크기의 블록을 반환하며, 기존 내용은 두 크기 중 작은 쪽까지 보존됩니다. 블록을 그 자리에서 키울 수도 있고 옮길 수도 있으므로, 이후에 계속 쓸 수 있는 포인터는 반환된 것뿐입니다. 예전 포인터는 이미 해제되었을 수 있습니다.
왜 realloc의 결과를 임시 포인터에 대입해야 하나요?
p = realloc(p, n)은 호출이 실패하면 p를 NULL로 덮어쓰는데, 원래 블록은 여전히 할당된 채 아무도 가리키지 않게 됩니다. 곧 메모리 누수죠. 먼저 tmp에 대입하고 NULL 여부를 확인한 뒤에만 p = tmp를 쓰세요.
malloc 대신 calloc은 언제 써야 하나요?
메모리가 정말로 0이어야 할 때(카운터 배열, 필드가 비어 있는 상태로 시작해야 하는 구조체, 일부만 채울 버퍼)이거나, 크기가 커질 수 있는 '개수 × 요소 크기' 형태일 때입니다. 어차피 모든 바이트를 덮어쓸 거라면 malloc이 0으로 채우는 작업을 건너뜁니다.