- 동적 메모리 할당
- 힙(Heap)
- 2차원 배열의 동적 할당
- 노드(Node)
- Q&A
- 마치며
제가 C로 프로그래밍하면서 가장 귀찮고 어려웠던 부분은,
배열의 크기를 제 맘대로 조절하지 못한다는 점이었습니다.
우리가 배열의 크기를 정할 때는,
항상 컴파일 시간에 확정되어 있어야 합니다.
그런데, 프로그래밍을 하다보면 항상 정해져있는 경우는 잘 없습니다.
뭐...배열의 크기를 충분히 크게 지정해주면 괜찮지만,
메모리 공간이 낭비되기 때문에 영 꺼림칙하죠...
이런한 난관을 해결해줄 수 있는 방법이 바로
'동적 메모리 할당'입니다.
여기서 말하는 '동적'이라는 의미는 딱 정해진 것이 아닌, 가변적으로 변할 수 있다는 의미입니다.
'할당'이라는 말은,
우리가 배열을 정의하면 배열에 맞는 메모리의 특정한 공간이 배열을 나타내는 것처럼,
메모리의 특정한 부분을 사용할 수 있게 됩니다.
할당되지 않은 메모리 공간은 절대로 사용할 수 없습니다.
- 동적 메모리 할당(Dynamic Memory Alloctaion)
: 프로그래밍에서 실행 시간 사이에 사용할 메모리 공간을 할당하는 것
:<stdlib.h>
의malloc
함수를 이용
- 정적 메모리 할당(Static Memory Alloction)
: 프로그램이 실행되는 순간 메모리의 할당이 이루어짐
# include<stdio.h>
# include<stdlib.h>
int main(int argc, char **argv) {
int SizeOfArray;
int* arr;
printf("만들고 싶은 배열의 원소 수 : ");
scanf("%d", &SizeOfArray);
arr = (int*)malloc(sizeof(int) * SizeOfArray);
free(arr);
return 0;
}
위의 예제에서, 우리가 만들고 싶은 배열의 크기를 scanf
를 통해 변수SizeOfArray
에 입력받았습니다.
arr = (int*)malloc(sizeof(int) * SizeOfArray);
이 부분이 바로, 배열의 크기를 동적으로 할당해줍니다.
malloc
은 'Memory Allocation'의 약자입니다.
이 함수는 <stdlib.h>
에 있으니,
#include <stdlib.h>
를 꼭 추가해주셔야 합니다.
- malloc 함수
(<stdlib.h>
에 정의되어 있습니다.)void* malloc(size_t size);
: 메모리를 할당하는 함수
: 할당된 메모리는 반드시free
로 해제해야 한다.
- 인자(Argument)
: 할당하고자 하는 메모리 크기의 바이트 수
: 음이 아닌 정수값이 와야함- 반환(Return)
: 할당한 메모리의 시작 주소값
:(void *)
형이므로, 원하는 타입으로 캐스팅해줘야만 함
이 함수는,
인자로 전달된 크기의 바이트 수 만큼 메모리 공간을 만듭니다.
즉, 공간을 할당하게 되는 것이죠.
우리는 원소의 개수가 변수 ArrayOfSize
이고, 자료형은 int인 배열을 만들고자 하므로,
인자로 전달된 바이트 수는 '(int의 크기) * (SizeOfArray)'가 되곘죠.
이 때, int의 크기를 정확하게 하기 위해,
sizeof
키워드를 사용해줍니다.
따라서, (int의 크기) * (SizeofArray)
는
sizeof(int) * SizeofArray
가 되죠.
(2)리턴으로는, 자신이 할당한 메모리의 시작 주소를 리턴합니다.
이 때, 리턴형은 (void *)형이므로,
우리는 (int *)으로 형변환하여 arr
에 대입합니다.
따라서, arr
에는 malloc
이 할당해준 메모리를 이용할 수 있습니다.
즉, arr[SizeOfArray]
만큼 사용할 수 있습니다.
free(arr);
마지막에 사용된 free
는 우리가 할당받은 메모리 공간을 다시 컴퓨터에게 돌려주는 역할입니다.
- free 함수
(<stdlib.h>
에 정의되어 있습니다.)void free(void* ptr);
: 메모리를 해제하는 함수
:malloc, calloc, realloc
등으로 할당된 메모리는 반드시free
로 해제해야 한다.
- 인자(Argument)
: 해제하고자 하는 할당된 메모리- 반환(Return)
: 없음
이를 '해제(free)한다'고 하는데,
만약 메모리 공간을 다 썼음에도 불구하고,
free해주지 않으면 쓸데없이 메모리 공간이 낭비가 되겠죠.
이렇게 free를 해주지 않아 발생하는 문제를 '메모리 누수(Memory Leak)'이라고 합니다.
지난 시간에 배운 내용에서,
데이터 세그먼트의 구조를 배울 때,
힙(Heap)이 있었습니다.
스택(Stack), 데이터 영역, Read only Data부분은 당연히 malloc함수가 건들 수 없는 부분입니다.
위의 부분들은 반드시, Complie 떄에 100% 결정되어야 합니다.
하지만 힙은 다릅니다.
메모리의 힙은 사용자가 자유롭게 할당하거나 해제할 수 있습니다.
따라서 위의 예제에서 본 arr
은 힙에 존재합니다.
힙은 할당 및 해제가 자유롭지만,
그만큼 제대로 사용해야합니다.
만약 힙에서 할당을 했는데 해제를 안하면 그 만큼의 공간은 낭비가 됩니다.
다른 영역의 메모리는 컴퓨터가 알아서 하겠지만,
힙은 우리가 직접 다루는 영역이므로, 제대로 처리해주셔야 합니다.
2차원 배열의 동적 할당은 두 가지 방식을 생각할 수 있습니다.
포인터 배열은,
배열의 각 원소가 모두 포인터인 배열입니다.
따라서 각 원소가 다른 1차원 배열을 가리킬 수 있습니다.
따라서 우리는,
먼저 포인터 배열을 동적으로 할당한 뒤,
다시 포인터 배열의 각 원소들이 가리키는 1차원 배열을 동적으로 할당해주면,
마치 2차원 배열을 만든 것과 같은 효과를 냅니다.
# include<stdio.h>
# include<stdlib.h>
int main(int argc, char **argv) {
int x, y;
int** arr; // arr[x][y]를 만들꺼임
scanf("%d %d", &x, &y);
// int* 형의 원소를 x개 가지는 1차원 배열 생성
arr = (int**)malloc(sizeof(int*) * x);
for (int i = 0; i < x; i++) {
arr[i] = (int*)malloc(sizeof(int) * y);
}
printf("생성 완료!");
for (int i = 0; i < x; i++) {
free(arr[i]);
}
free(arr);
return 0;
}
차근차근 봅시다.
int** arr;
만약 우리가 int array[3];
이라는 배열을 만들었다면,
array
의 타입은 int *
입니다.
그렇다면 int *arr[10]
의 타입은 int **arr
입니다.
arr = (int**)malloc(sizeof(int*) * x);
위와 같이 int *
형 배열을 동적으로 할당합니다.
그렇다면, arr
의 각 원소들은 int *
이므로,
각 원소들을 다른 int배열을 가리켜야 합니다.
for (int i = 0; i < x; i++) {
arr[i] = (int*)malloc(sizeof(int) * y);
}
위를 통해, arr
의 각 원소들이,
크기가 y인 int형 배열을 가리킬 수 있게 합니다.
하지만, 위와 같이 만들어진 배열은
엄밀히 2차원 배열은 아닙니다.
왜냐 하면, 자고로 배열을
메모리 상에서 연속적으로 존재해야 하지만,
위의 배열은 연속적으로 존재한다고 보장할 수 없습니다.
그리고 우리가 만든 배열은 함수의 인자로 손쉽게 전달할 수 있습니다.
예를 들어
int array(int **array);
우리는 array
라는 함수에 arr
이라는 배열을 인자로 전달할 수 있습니다.
그 이유는, 우리가 만든 배열은 1차원 배열이지,
2차원 배열이 아니기 때문입니다.
1차원 배열은 인자로 넘길 때, 크기를 신경쓰지 않아도 되니깐요.
(main함수에 인자로 전달하는 char **argv
도 마찬가지 입니다.)
그래도 2차원 배열처럼 사용할 수 있습니다!!
예를 들어 arr[3][4]
는 *(*(arr + 3) + 4)
로 해석 됩니다.
*(arr + 3)
은 arr
의 4번째 원소에 접근하고,
이후, 자신이 가리키는 5번째 원소에 접근합니다.
이렇게 할당을 해주면 뭐다? 해제를 해야한다 😉
arr
을 먼저 해제해주면, arr[i]
들이 메모리 상에서 사지게 되므로,
arr[i]
가 가리키던 int형 배열들은 해제할 수 없으므로,
꼭, 안에서부터 해제시켜야 합니다.
for (int i = 0; i < x; i++) {
free(arr[i]);
}
free(arr);
(이 방법은 gcc나 clang과 같은 컴파일러에서 사용 가능합니다. 비쥬얼 스튜디오는 안되네요...😢
비쥬얼 스튜디오의 C 컴파일러가 VLA를 지원하지 않아서래요...)
진짜 2차원 배열을 만듭니다.
예를 들어 int arr[height][width];
와 같은 배열을 만들고자 하면,
필요한 메모리의 크기는
height * width * sizeof(int)
가 됩니다.
이제 컴퓨터에게 해당 메모리를 "2차원 배열이야"라고 알려줘야 합니다.
따라서, 해당 메모리 주소값을 2차원 배열을 가리키는 포인터에게 전달하면 됩니다.
int (*arr)[width] = (int (*)[width])malloc(height * width * sizeof(int));
위 문장을 통해, 컴파일러는
"행의 크기가 width
인 2차원 배열을 가리키는구나!"
라고 생각할 수 있습니다.
arr
을 정의할 때 반드시 width
에는 실제 배열의 값이 들어간 후에 정의해야 합니다.
그러니까
int width;
int (*arr)[width] = (int (*)[width])malloc(height * width * sizeof(int));
scanf("%d", &width);
위처럼 하게 되면,
사전에 width는 정의되지 않았으므로,
2차원 배열을 참조할 수 없습니다.
그리고 이렇게 만든 배열을 해제할 때는
1번 방법(포인터 배열을 이용)한 것처럼 arr[i]
의 원소를 해제하고, 마지막에 arr
을 해제하지 않아도 됩니다.
그냥 바로 free(arr)
만 해도 됩니다.
왜냐 하면, 여기서 arr
은 메모리에 연속적으로 존재하기 때문이죠.
그런데 주의점이 있습니다.
바로, 해당 배열 포인터를 함수의 인자로 넘겨줄 때 입니다.
예를 들어, 다음 코드를 봅시다.
void print_array(int (*arr)[width], int width, int height) {
for (int i = 0; i < height; i++) {
for (int j = 0; j < width; j++) {
printf("%d ", arr[i][j]);
}
printf("\n");
}
}
컴파일러는
int (*arr)[width]
를 보고 width
가 뭔지 알 수 없다는 것입니다.
따라서 컴파일러가 알 수 있게
width
를 arr
앞에 위치 시켜주면 됩니다.
void print_array(int width, int (*arr)[width], int height) {
for (int i = 0; i < height; i++) {
for (int j = 0; j < width; j++) {
printf("%d ", arr[i][j]);
}
printf("\n");
}
}
그렇다면 어떤 방법을 사용해야 할까요? 🤔
되도록이면 2번 방법(연속적인 공간에 2차원 배열을 할당)이 좋습니다.
그 이유는
malloc
은 상당히 느린 함수 중 하나입니다.malloc
의 호출을 최소한으로 해주는 것이 좋습니다. 그런데 1번 방법은 height
가 커지면 커질 수록 상당히 느려지죠.arr[3][2]
에 접근할 때는, arr[3]
을 읽은 뒤에 그 주소에서 [2]
연산을 해야합니다. 하지만 2번 방법은 바로 arr[3][2]
에 접근 가능합니다.물론 후자의 방식이 코드가 조금 더 길지만, 효율은 후자가 훨씬 좋네요...
만약에 우리가 이미 동적할당을 100의 크기만큼 한 상황에서,
1개를 더 추가하고자 하면 어떻게 해야할까요?
101개의 크기로 다시 할당해줘야할까요?
이건..너무 비효율적이죠(시간 + 메모리 낭비)
이럴 때, '노드'를 사용할 수 있습니다.
일단 노드는 다음과 같이 생겼습니다.
코드로는 다음과 같습니다.
struct Node{
int data;
struct Node* nextNode;
};
노드는 다음과 같이 사용합니다.
첫 번째 노드는 다음 노드를,
다음 노드는 그 다음 노드를,
그렇게 계속해서 이어가다가,
마지막 노드는 아무것도 가리키지 않습니다.
그리고 각 노드는 자신만의 데이터를 가지고 있죠.
만약, 우리가 데이터를 한 개 추가한다고 하면,
그냥 마지막 노드에서 새 노드를 이어주기만 하면 됩니다.
뿐만 아니라, 배열에서 할 수 없던
'배열 중간에 새 원소 집어넣기'가 가능해집니다.
즉, 노드 사이에 새로운 노드를 추가할 수 있습니다.
위 사진처럼,
기존에 있던 연결을 없애버리고, 그 사이에 새롭게 연결해주기만 하면 됩니다.
그렇다면 코드는 어떨까요?
우선 새로운 노드를 생성하는 CreateNode
함수입니다.
struct Node* CreateNode(int data) {
struct Node* newNode = (struct Node*)malloc(sizeof(struct Node));
newNode->data = data;
newNode->nextNode = NULL;
return newNode;
}
위에서 만들어진 노드는 그냥 데이터만 가지고 있고,
어떠한 관계도 없는 노드입니다.
따라서 어떠한 노드 뒤에 새로운 노드를 생성하는 함수 InsertNode
를 만들어봅시다.
이 때, 새로운 노드 '앞에 있는 노드'와 '새로운 노드를 위한 데이터'가 필요하므로,
struct Node *currnet, int data
가 인자로 필요합니다.
//current 노드 뒤에 새로운 노드를 만들어 넣는 함수
struct Node* InsertNode(struct Node* current, int data) {
//after는 current 노드가 가리키고 있던 다음 노드이다
struct Node* after = current->nextNode;
//새로운 노드 생성
struct Node* newNode = (struct Node*)malloc(sizeof(struct Node));
//새로운 노드의 값 설정
newNode->data = data;
newNode->nextNode = after;
//current는 이제 newNode를 가리켜야 한다
current->nextNode = newNode;
return newNode;
}
노드를 추가할 수도 있으면, 제거할 수도 있습니다.
이를 위해, 제거하고자 하는 노드를 가리키는 바로 앞의 노드를 알아야 합니다.
따라서, 앞의 노드를 찾기 위해 우리는,
맨 앞의 노드('헤드'라고 합니다)부터 찾아가야 합니다.
즉, 우리가 만들고자하는 함수 DestroyNode
는 인자로 헤드와 제거하고자하는 노드를 받아야 합니다.
//특정 노드를 제거하는 함수
void DestroyNode(struct Node* destroy, struct Node* head) {
//다음 노드를 가리킬 포인터
struct Node* next = head;
//head를 제거하고자 한다면
if (destroy == head) {
free(destroy);
return;
}
//만약 next가 NULL이면 종료
while (next) {
//만약 next 다음 노드가 destroy면,
//next가 desrtroy의 앞 노드
if (next->nextNode == destroy) {
//따라서 next의 다음 노드는 destroy가 아닌,
//destroy의 다음 노드가 되어야 함
next->nextNode = destroy->nextNode;
}
//next는 다음 노드를 가리킨다.
next = next->nextNode;
}
free(destroy);
}
-
이제 거의 C 강좌가 끝나갑니다...
엉엉...너무 힘드네요
날씨도 엄청 덥고 습해서 공부하기가 너무 힘듭니다...
지금도 땀을 뻘뻘 흘리면서 기록하고 있네요...
[Reference] : 위 글은 다음 내용을 참고, 인용하여 만들어졌습니다.