C를 C답게 만드는 것은 포인터와 직접 관리하는 메모리입니다. 포인터를 이해하면 배열, 문자열, 함수 인수, 동적 자료 구조가 모두 하나의 원리로 연결됩니다. 동시에 C의 버그 대부분도 여기에서 나옵니다. 이 장에서는 주소와 포인터, 포인터 산술, malloc과 free, 소유권 관례, 그리고 피해야 할 정의되지 않은 동작을 정리합니다.
주소와 포인터
모든 변수는 메모리의 어딘가에 있고, 그 위치가 주소입니다. &x는 x의 주소를 구하고, 포인터 변수는 그 주소를 담습니다. 포인터 앞에 *를 붙이면(역참조) 가리키는 곳의 값을 읽거나 쓸 수 있습니다.
#include <stdio.h>
void swap(int *a, int *b) { /* 주소를 받아 호출자의 변수를 바꿈 */
int tmp = *a;
*a = *b;
*b = tmp;
}
int main(void) {
int x = 10, y = 20;
int *p = &x; /* p는 x의 주소를 담음 */
printf("x의 주소: %p\n", (void *)p);
*p = 15; /* x가 15로 바뀜 */
swap(&x, &y);
printf("x=%d y=%d\n", x, y); /* x=20 y=15 */
int *nothing = NULL; /* 아무것도 가리키지 않음 */
if (nothing != NULL) {
printf("%d\n", *nothing);
}
return 0;
}
2장에서 C의 인수는 값으로 전달된다고 했습니다. 포인터도 값으로 전달되지만, 그 값이 주소이므로 함수가 호출자의 변수를 바꿀 수 있습니다. 가리킬 곳이 없는 포인터는 NULL로 초기화하고, 역참조하기 전에 확인합니다.
포인터 산술과 배열
포인터에 정수를 더하면 바이트가 아니라 가리키는 타입 크기만큼 이동합니다. int *p에서 p + 1은 다음 int의 주소입니다. 배열 이름은 대부분의 식에서 첫 요소를 가리키는 포인터로 바뀌므로 a[i]는 *(a + i)와 같습니다.
#include <stdio.h>
#include <stddef.h>
int sum(const int *begin, const int *end) {
int total = 0;
for (const int *p = begin; p != end; p++) {
total += *p;
}
return total;
}
int main(void) {
int a[] = {1, 2, 3, 4, 5};
size_t n = sizeof a / sizeof a[0];
int *p = a; /* &a[0]과 같음 */
printf("%d %d\n", *(p + 2), a[2]); /* 3 3 */
int *last = &a[n - 1];
ptrdiff_t dist = last - p; /* 두 포인터 사이 요소 수: 4 */
printf("%td\n", dist);
printf("%d\n", sum(a, a + n)); /* a + n은 끝 바로 다음: 비교만 가능 */
return 0;
}
포인터는 배열 안이나 배열 끝 바로 다음 위치까지만 가리킬 수 있습니다. 끝 바로 다음 위치는 비교에만 쓰고 역참조하면 안 됩니다. 서로 다른 배열을 가리키는 두 포인터를 빼거나 크기를 비교하는 것도 정의되지 않은 동작입니다.
malloc과 free
크기를 실행 중에 정해야 하거나 함수가 끝난 뒤에도 살아 있어야 하는 데이터는 힙에 할당합니다. <stdlib.h>의 함수를 씁니다.
malloc(size): size 바이트를 할당합니다. 내용은 초기화되지 않습니다.calloc(count, size): 할당한 메모리를 0으로 채웁니다.realloc(ptr, size): 크기를 바꿉니다. 주소가 바뀔 수 있습니다.free(ptr): 할당한 메모리를 돌려줍니다. free(NULL)은 아무 일도 하지 않습니다.
#include <stdio.h>
#include <stdlib.h>
int main(void) {
size_t cap = 4, len = 0;
int *items = malloc(cap * sizeof *items);
if (items == NULL) return 1; /* 할당 실패 확인 */
for (int v = 1; v <= 10; v++) {
if (len == cap) {
size_t new_cap = cap * 2;
int *tmp = realloc(items, new_cap * sizeof *tmp);
if (tmp == NULL) { /* 실패해도 items는 그대로 유효 */
free(items);
return 1;
}
items = tmp;
cap = new_cap;
}
items[len++] = v * v;
}
printf("len=%zu cap=%zu last=%d\n", len, cap, items[len - 1]);
free(items);
items = NULL; /* 해제 후 다시 쓰지 않도록 */
return 0;
}
realloc의 결과를 원래 포인터에 바로 대입하면 실패했을 때 원래 메모리 주소를 잃어 누수가 생깁니다. 항상 임시 변수에 받은 뒤 성공했을 때만 바꿉니다. sizeof *items처럼 변수에서 크기를 구하면 타입을 바꿔도 크기 계산이 따라 바뀝니다.
소유권 관례
C에는 메모리를 자동으로 해제하는 장치가 없으므로 "누가 이 메모리를 해제하는가"를 코드와 문서로 정해야 합니다. 널리 쓰이는 관례는 다음과 같습니다.
- 만든 쪽이 해제합니다.
xxx_create가 할당하면 짝이 되는 xxx_destroy를 제공합니다. const T * 매개변수는 빌려 쓰기만 한다는 뜻입니다. 함수가 해제하거나 보관하지 않습니다.- 새로 할당한 메모리를 돌려주는 함수는 "호출자가
free해야 한다"고 주석에 적습니다. - 한 메모리를 해제하는 책임자는 언제나 하나입니다.
#include <stdlib.h>
#include <string.h>
typedef struct {
char *name; /* 이 구조체가 소유: destroy에서 해제 */
int score;
} Player;
/* 성공하면 새 Player를 돌려준다. 호출자는 player_destroy로 해제해야 한다. */
Player *player_create(const char *name, int score) {
Player *p = malloc(sizeof *p);
if (p == NULL) return NULL;
p->name = malloc(strlen(name) + 1);
if (p->name == NULL) {
free(p);
return NULL;
}
strcpy(p->name, name); /* 크기를 정확히 맞춰 할당했으므로 안전 */
p->score = score;
return p;
}
void player_destroy(Player *p) {
if (p == NULL) return;
free(p->name);
free(p);
}
피해야 할 정의되지 않은 동작
정의되지 않은 동작(UB)은 표준이 결과를 전혀 정하지 않은 동작입니다. 우연히 잘 동작하다가 컴파일러나 최적화 수준이 바뀌면 다르게 동작할 수 있습니다.
경고(-Wall -Wextra), 새니타이저(-fsanitize=address,undefined), 그리고 Valgrind 같은 도구를 함께 쓰면 이런 문제 대부분을 개발 중에 찾을 수 있습니다.
정리
- 포인터는 주소를 담고,
&로 주소를 구하고 *로 역참조합니다. 쓰기 전에 NULL인지 확인합니다. - 포인터 산술은 타입 크기 단위로 움직이며, 배열 범위와 끝 바로 다음 위치 안에서만 유효합니다.
malloc 결과는 항상 확인하고, realloc은 임시 변수로 받고, 할당한 메모리는 정확히 한 번 해제합니다.- 누가 메모리를 소유하고 해제하는지 create/destroy 짝과 주석으로 분명히 합니다.
- 정의되지 않은 동작은 도구로 찾고 코드 규칙으로 막습니다.
더 읽을 거리
댓글 0개
로그인 · 로그인하면 댓글을 남길 수 있습니다.