출시·고도화 중
C 안내서 · 6/6
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;
}포인터는 배열 안이나 배열 끝 바로 다음 위치까지만 가리킬 수 있습니다. 끝 바로 다음 위치는 비교에만 쓰고 역참조하면 안 됩니다. 서로 다른 배열을 가리키는 두 포인터를 빼거나 크기를 비교하는 것도 정의되지 않은 동작입니다.
크기를 실행 중에 정해야 하거나 함수가 끝난 뒤에도 살아 있어야 하는 데이터는 힙에 할당합니다. <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)은 표준이 결과를 전혀 정하지 않은 동작입니다. 우연히 잘 동작하다가 컴파일러나 최적화 수준이 바뀌면 다르게 동작할 수 있습니다.
| 동작 | 예 | 대책 |
|---|---|---|
| 널 포인터 역참조 | *p (p가 NULL) | 역참조 전에 확인 |
| 범위 밖 접근 | a[n] | 길이를 함께 넘기고 검사 |
| 해제 후 사용 | free(p); p->x | 해제 후 NULL 대입 |
| 이중 해제 | free(p); free(p); | 소유자를 하나로 정함 |
| 초기화하지 않은 값 읽기 | int x; x + 1 | 선언과 함께 초기화 |
| 지역 변수 주소 반환 | return &local; | 힙 할당 또는 호출자 버퍼 사용 |
| 부호 있는 정수 오버플로 | INT_MAX + 1 | 연산 전에 범위 확인 |
| 문자열 리터럴 수정 | char *s = "hi"; s[0] = 'H'; | char s[] = "hi"; 사용 |
경고(-Wall -Wextra), 새니타이저(-fsanitize=address,undefined), 그리고 Valgrind 같은 도구를 함께 쓰면 이런 문제 대부분을 개발 중에 찾을 수 있습니다.
&로 주소를 구하고 *로 역참조합니다. 쓰기 전에 NULL인지 확인합니다.malloc 결과는 항상 확인하고, realloc은 임시 변수로 받고, 할당한 메모리는 정확히 한 번 해제합니다.
댓글 0개
로그인 · 로그인하면 댓글을 남길 수 있습니다.
첫 댓글을 남겨 보세요.