1. 동적 메모리 할당
(1) 기본
지금까지 살펴본 C의 자료구조는 고정된 크기를 갖고 있었다. 일반 배열의 경우 프로그램이 컴파일되면 원소의 개수가 고정이 되고, C99의 가변 크기 배열 VLA의 경우에는 런타임에 배열의 크기가 동적으로 결정되지만 그 이후로는 크기가 고정된다. 프로그램 실행 중에 필요한 메모리 크기를 알 수 없는 경우가 많기 때문에, 이러한 자료구조만으로는 유연한 메모리 관리가 불가능하고 메모리 효율성도 낮을 수 밖에 없다.
때문에 C언어에서는 동적 메모리 할당이 존재한다. 말 그대로 동적으로 메모리를 할당하는 개념이기에, 런타임에 메모리를 할당하거나 해제할 수 있어 필요에 따라 자료구조의 크기를 늘리거나 줄이는 것이 가능해진다.
(2) 동적 메모리 할당 함수
동적 메모리 할당에서는 stdlib.h의 malloc, calloc realloc이라는 함수가 사용된다.
- malloc(n * sizeof(int 등)) : 메모리 블록을 할당하되 초기화하진 않음
- calloc(n, sizeof(int 등)) : 메모리 블록을 할당하고 이를 0으로 초기화 (굳이 초기화할 필요가 없어 malloc이 더 효율적임)
- realloc(ptr, size) : 이전에 할당된 메모리 블록의 크기를 조정
이들 함수를 호출하는 경우에 함수는 메모리 블록에 사용자가 어떤 타입의 데이터를 저장할지 알 수 없기 때문에 void* 타입을 반환한다. 이 void* 타입은 특정한 데이터 타입에 구애받지 않는 일반 포인터를 의미한다. 또한 이들 메모리 할당 함수를 호출하는 경우에는 요청한 크기만큼의 메모리 블록을 찾지 못할 가능성이 항상 존재한다. 이러한 경우에는 이들 함수는 널 포인터를 반환하게 된다. 사용자는 함수를 다룰때 이렇게 널 포인터가 반환되는 경우를 적절하게 대비해야 한다.
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
char* append_to_string(const char* original, const char* to_append) {
// 원본 문자열의 길이 계산
size_t original_len = strlen(original);
size_t append_len = strlen(to_append);
// 새로운 문자열을 위한 메모리 할당
char* new_string = (char*)malloc(original_len + append_len + 1); // +1은 null 문자 용
if (new_string == NULL) {
return NULL; // 메모리 할당 실패 시 NULL 반환
}
// 문자열 복사 및 연결
strcpy(new_string, original);
strcat(new_string, to_append);
return new_string; // 새로운 문자열 반환
}
(3) 메모리 해제
malloc과 같은 메모리 할당 함수는 메모리의 heap영역으로부터 메모리 블록을 가져온다.
메모리의 힙 영역은 런타임에 동적으로 관리되지만, 시스템 전체의 메모리 용량과 단편화 등의 제약을 받는다. 따라서 malloc과 같은 함수를 너무 자주 호출하거나 너무 큰 메모리 블록을 요청하는 경우, 요청한 크기의 연속된 메모리를 확보하지 못해 NULL 포인터를 반환할 수 있다.
따라서 동적으로 메모리를 할당했으면, 더이상 사용하지 않을 경우 할당된 메모리 블록을 반환해줄 필요가 있다. 다음 예시 코드를 살펴보자.
p = malloc(...);
q = malloc(...);
free(p);
p = q;
stdlib.h의 free 함수는 포인터가 가리키는 메모리 블록을 해제해준다. 해제된 블록은 다른 메모리 할당 함수의 호출에서 다시 사용될 수 있게 된다.
위 예시코드에서 p 포인터는 q포인터가 가리키는 메모리 블록을 가리키게 되는데, 만약 free(p); 부분이 없다면 원래 p가 가리키고 있던 메모리 블록은 해제되지 않고, 프로그램에서 더이상 접근도 할 수 없게 된다. 이러한 메모리 블록을 garbage라고 하고, garbage를 남기는 프로그램은 메모리 누수가 있다고 한다. C 프로그램에서는 불필요한 메모리를 free함수를 통해 직접 해제시켜 주어야 한다.
반대로 free를 통해 p가 가리키고 있던 메모리 블록을 해제한 뒤에 p를 사용하려고 하면 어떻게 될까? 여전히 해제된 메모리 영역을 가리키고 있는 포인터를 허상 포인터(dangling pointer)라고 한다. 이렇게 해제된 메모리 블록에 접근하거나 수정하려고 하면 정의되지 않은 동작이 발생하여 큰 위험을 초래할 수 있다고 한다.
2. 포인터 활용
(1) 이중 포인터
이중 포인터는 포인터의 포인터, 즉 다른 포인터 변수의 주소를 갖는 포인터를 말한다. 초심자의 입장에서 이중 포인터를 어떻게 활용해야 할지 바로 연상되지 않는데, 교재에 나온 예시인 '연결 리스트의 맨 앞에 새로운 노드를 추가하는 함수'를 살펴보기로 하자.
첫 번째 방식으로, 함수가 새 헤드 포인터를 return하는 방식을 생각해 볼 수 있다.
struct node *add_to_list(struct node *first, int n) {
struct node *new_node = malloc(sizeof(struct node));
if (new_node == NULL) {
printf("Error: malloc failed in add_to_list\n");
exit(EXIT_FAILURE);
}
new_node->value = n;
new_node->next = first;
return new_node; // 새로 추가된 노드를 반환 (이 노드가 리스트의 head가 됨)
}
// 위 함수를 다음과 같이 호출해야 함
first = add_to_list(first, 10);
이번엔 두 번쨰 방식으로 이중 포인터를 활용하여 함수 내부에서 직접 first를 갱신하는 방식을 살펴보자.
void add_to_list(struct node **list, int n) {
struct node *new_node = malloc(sizeof(struct node));
if (new_node == NULL) {
printf("Error: malloc failed in add_to_list\n");
exit(EXIT_FAILURE);
}
new_node->value = n;
new_node->next = *list; // *list가 원래 가리키던 노드를 새 노드의 next로
*list = new_node; // *list가 새 노드를 가리키도록 변경
}
// 위 함수를 다음과 같이 호출한다
add_to_list(&first, 10);
이중 포인터라고 어렵게 생각할 것 없고, 함수 내부에서 변수의 값을 변경해야 하는데 그 변수가 포인터 변수이기 때문에 포인터를 가리키는 이중 포인터가 필요하게 된 것이다. C언어에서 함수에 값을 전달하는 방식은 call by value임을 상기하자.
(2) 함수 포인터
프로그램에서 함수들은 메모리의 코드 영역에 저장된다. 즉 함수들 또한 변수들과 마찬가지로 메모리 주소를 갖고 있는 것이다. 따라서 C언어에서는 이 함수의 주소를 저장할 수 있는 포인터를 만들 수 있는데, 이를 함수 포인터라고 한다. 함수 포인터는 1) 함수의 인자 2) 함수 포인터 배열 등으로 활용이 가능한데, 간단한 1)의 예시를 살펴보자.
#include <stdio.h>
int add(int x, int y){
return x + y;
}
int main() {
int (*pf)(int, int);
pf = add;
printf("%d", pf(2, 3));
return 0;
}
예시에서 알 수 있듯이 함수 포인터의 매개변수와 반환 타입은 실제 할당할 함수의 프로토타입과 정확히 일치해야 한다. 참고로 위 예시의 pf = add; 부분과 관련하여, 함수의 이름 뒤에 괄호가 붙지 않으면 그 함수의 주소로 해석된다. 마치 배열 이름 뒤에 대괄호가 없으면 배열의 시작 주소가 전달되는 것과 비슷하다. 따라서 pf = &add;와 같이 써줄 필요가 없게 된다.
(3) restrict 포인터
restrict 포인터란 C99에서 도입된 restrict 키워드를 사용해 선언된 포인터이다. 예를들어 int * restrict p;와 같이 선언할 수 있는데, 이는 p로 가리키는 객체는 오직 p를 통해서만 접근된다는 의미를 갖는다. 즉, restrict 키워드로 선언된 포인터는 그 포인터를 통해 접근하는 메모리 영역에 다른 포인터가 접근하지 않음을 보장한다. 만약 이를 위반할 경우 정의되지 않은 동작을 야기할 수 있다.
restrict 포인터는 컴파일러 최적화에서 의의를 갖는다. restrict 키워드는 별칭이 없다는 프로그래머의 약속이고, 컴파일러는 이를 전제로 하여 효율적인 최적화를 진행할 수 있다.
3. 기타 알게된 것들
- NULL 매크로는 <locale.h>, <stddef.h>, <stdio.h>, <stdlib.h>, <string.h>, <time.h>, <wchar.h> 헤더에 정의되어 있기 때문에 이들 헤더를 포함하면 컴파일러는 NULL을 인식한다.
- NULL의 정의는 다음과 같다.
#define NULL (void *) 0
(void *) 부분은 가독성과 의도를 명확히 하는 측면이 있다. (즉, 빼도 무방한듯) - if (p == NULL) 은 if (!p) 와 같이 써도 되고, 반대로 if (p != NULL) 은 if (p)로 써도 된다.
- realloc에 전달된 포인터는 반드시 malloc, calloc, realloc을 통해 할당된 메모리에서 온 것이어야 하고, 그렇지 않은 경우 realloc 호출시 정의되지 않은 동작이 발생한다.
- C언어와 달리, 파이썬 등은 garbage를 자동으로 찾아 해제하는 기능인 garbage collector 기능을 채택하였다.
- C언어에는 정렬 함수가 존재했었다..! stdlib.h의 qsort함수로, 퀵정렬 알고리즘을 사용하여 배열을 정렬하는 함수이다. qsort(arr, n, sizeof(int), compare);와 같이 사용하고, 비교함수 compare은 직접 구현해주어야 한다.
- C99이전에는 구조체 안에 길이가 유동적인 배열을 두기 어려웠기 때문에, 배열 크기를 1로 선언 후 추가 메모리를 할당하는 일종의 편법을 썼다고 한다. C99에는 Flexible Array Member가 도입되면서 구조체 끝에 크기를 정하지 않은 배열을 선언하여 동적으로 필요한 크기만큼만 할당받을 수 있도록 하였다.
'독서 > C Programming : A Modern Approach' 카테고리의 다른 글
[KNK] chapter 16 : 구조체, 공용체, 열거형 (0) | 2025.02.12 |
---|---|
[KNK] chapter 15 : 대규모 프로그램 작성법 (0) | 2025.01.03 |
[KNK] chapter 14 : 전처리기 (0) | 2024.12.31 |
[KNK] chapter 13 : 문자열 (0) | 2024.11.21 |
[KNK] chapter 12 : 포인터와 배열 (0) | 2024.11.18 |