C언어 (22) 동적 메모리 할당
프로그램에서 사용되는 메모리는 정적 메모리와 동적 메모리가 있다.
우리가 선언하던 일반 배열들은 컴파일 할 때 메모리의 크기가 정해진다.
이런 정적 메모리는 프로그램을 실행하면서 메모리의 크기를 변경하는 것이 불가능하다.이 문제점을 해결 하기 위해서 동적으로 메모리를 할당하고 해제하는 작업이 필요하다.
프로그램이 실행되는 중에 메모리를 할당한다면 더 효율적으로 메모리를 관리 할 수 있기 때문이다.
동적 메모리
프로그램을 실행하면서 생성하는 메모리는 힙 메모리에 할당된다.
이렇게 힙 영역에 할당된 동적 메모리는 일반 변수가 아닌 포인터를 통해 접근할 수 있다.
동적 할당이 필요한 이유를 간단히 알아보면 이는 배열의 문제점과 연관이 있다.
배열의 문제점
- 선언된 배열 크기가 사용된 요소 보다 많으면 메모리가 낭비된다.
- 선언된 배열 크기가 사용된 요소보다 적으면 메모리가 부족하다.
- 배열 선언 시 배열 길이에 변수를 설정하면 에러가 발생한다.
이 문제는 배열이 고정된 메모리를 할당 받고, 프로그램 종료까지 영역을 해제할 수 없기 때문이다.
프로그래머가 필요한 메모리 크기를 예측할 수 없기 때문에 배열의 문제점이 드러나게 된다.
프로그램을 실행하면서 필요한 시점에 메모리를 원하는만큼 할당할 수 있다면 문제를 해결할 수 있다.
동적 메모리 할당 함수
함수명 | 설명 | 반환 값 |
---|---|---|
malloc(size) | 지정된 크기만큼 메모리를 할당하며 초기화되지 않은 메모리를 반환. | 성공 시 할당된 메모리의 포인터 반환, 실패 시 NULL 반환 |
calloc(n, size) | 지정된 개수와 크기만큼 메모리를 할당하고 모든 바이트를 0으로 초기화. | 성공 시 할당된 메모리의 포인터 반환, 실패 시 NULL 반환 |
realloc(ptr,size) | 기존 할당된 메모리 블록을 새로 지정된 크기만큼 재할당 | 성공 시 재할당된 메모리의 포인터 반환, 실패 시 NULL 반환. 기존 포인터는 영향을 받지 않음 |
free(ptr) | 할당된 메모리를 해제. | 반환 값 없음, 메모리 해제 후 포인터는 더 이상 유효하지 않음 |
이렇게 메모리를 할당하는 함수 3개와 해제하는 free() 함수로 나눌 수 있다.
동적 메모리 함수들은 헤더파일 stdlib.h에 선언되어 있다.
malloc & free
1
void* malloc(size_t size);
동적 메모리를 할당하기 위해 가장 많이 사용하는 함수는 malloc이다.
할당에 성공하면 할당한 메모리의 시작 주소를 반환하고, 실패하면 NULL을 반환한다.
size_t는 unsigned 자료형으로 선언되어 있으므로, 입력 인자로 0보다 큰 정수를 입력해주면 된다.
또, 반환 자료형은 void* 주소이므로 사용자가 형변환해서 사용해야 한다.
free 함수의 원형은
1
void free(void* p)
로 주소를 인자로 받아 힙 영역에 할당된 메모리를 해제해준다.
할당한 메모리를 해제하지 않으면 그만큼의 메모리가 낭비되므로 꼭 해제를 해줘야 한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#include <stdio.h>
#include <stdlib.h>
int main()
{
int* p;
p = (int*)malloc(4);
if (p == NULL) {
printf("할당 실패\n");
}
printf("초기화 전 값: %d\n", *p);
*p = 10;
printf("주소 : %p\n", p);
printf("값: %d\n", *p);
free(p);
printf("주소 : %p\n", p);
p = NULL;
printf("주소 : %p\n", p);
return 0;
}
1
2
3
4
5
초기화 전 값: 1855479264
주소 : 000001786E988AB0
값: 10
주소 : 000001786E988AB0
주소 : 0000000000000000
malloc으로 4byte를 할당하고, int*로 형변환해서 p에 저장한다.
할당된 메모리에 p로 접근해서 출력해보면 malloc은 자동으로 초기화해주지 않기에 쓰레기 값이 있다.
그 값을 10으로 초기화 한 뒤, 출력하고, free를 통해서 할당된 영역을 해제해준다.
이 때 free는 메모리를 해제해주는 역할이지, p를 초기화 해주지는 않는다.
이 후 여전히 주소가 남아있는 p에 접근하면 잘못된 영역에 접근하는 것이므로 오류가 발생할 수 있다.
그렇기에 사용 후에 p를 NULL로 초기화하는 것이 보다 안전한 사용 방법이라고 볼 수 있다.
또, 할당받을 메모리의 크기를 위의 코드에서는 4라는 숫자 상수로 전달해줬는데,
1
2
3
p = (int*)malloc(4);
p = (int*)malloc(sizeof(int));
일반적으로는 이렇게 할당받을 메모리를 사용할 자료형의 크기로 표현해주는 것이 좋다.
숫자보다 sizeof 함수로 표현하는 것이 코드에서 직관적으로 메모리를 어떻게 사용할지 드러나기 때문이다.
calloc & free
calloc()은 malloc()과 똑같이 힙 영역에 동적 메모리를 할당한다.
1
calloc(size_t num, size_t size);
하지만 원형을 보면 이렇게 인자가 2개 필요하고, num은 할당할 요소의 개수, size는 각 요소의 크기이다.
그러므로 총 할당되는 크기는 num * size가 된다.
배열의 각 요소의 크기와 요소의 개수로 할당이 된다고 생각해도 좋다.
calloc은 왜 2개의 인자가 필요하게 설계했을까?
0으로 초기화하는 과정이 있으니 각 메모리의 크기를 아는 것이 더 편해서.. 라고 생각했는데
https://pang2h.tistory.com/252 를 읽어보니 코드 상으로 특별한 이유는 없는 것 같네요..
그래도 이렇게 인자 2개를 받기에 배열을 할당할 때 좀 더 명확하게 표현할 수 있다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#include <stdio.h>
#include <stdlib.h>
int main()
{
int* p;
p = (int*)calloc(1, sizeof(int));
if (p == NULL) {
printf("할당 실패\n");
}
printf("초기화 전 값: %d\n", *p);
*p = 10;
printf("주소 : %p\n", p);
printf("값: %d\n", *p);
free(p);
printf("주소 : %p\n", p);
p = NULL;
printf("주소 : %p\n", p);
return 0;
}
1
2
3
4
5
초기화 전 값: 0
주소 : 00000262DEED8A80
값: 10
주소 : 00000262DEED8A80
주소 : 0000000000000000
위의 코드와 거의 같다.
int형 변수 1개만큼만 할당할 것이기에 num은 1, size는 4, 총 4byte로 calloc을 호출했다.
결과에서 다른 점은 초기화를 하지 않았는데도 할당받은 메모리가 0으로 초기화된 것이다.
배열 할당
1차원 배열의 할당은 간단하게 표현할 수 있다.
크기가 5인 int형 배열을 선언하려면
1
2
(int*)malloc(sizeof(int) * 5); 또는
(int*)calloc(5, sizeof(int));
로 가능하다. 이렇게 선언하면 총 20byte가 할당되고, int*으로 타입 변환도 잘 된다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
#define _CRT_SECURE_NO_WARNINGS
#include <stdio.h>
#include <stdlib.h>
int main()
{
int arr_size;
int* arr_ma;
int* arr_ca;
printf("만들고 싶은 배열의 원소의 수 : ");
scanf("%d", &arr_size);
arr_ma = (int*)malloc(sizeof(int) * arr_size);
arr_ca = (int*)calloc(arr_size, sizeof(int));
for (int i = 0; i < arr_size; i++) {
arr_ma[i] = i + 1;
arr_ca[i] = i * 2;
}
for (int i = 0; i < arr_size; i++) {
printf("%d ", arr_ma[i]);
}printf("\n");
for (int i = 0; i < arr_size; i++) {
printf("%d ", arr_ca[i]);
}
free(arr_ma);
free(arr_ca);
arr_ma = NULL;
arr_ca = NULL;
return 0;
}
1
2
3
만들고 싶은 배열의 원소의 수 : 10
1 2 3 4 5 6 7 8 9 10
0 2 4 6 8 10 12 14 16 18
만들고 싶은 크기를 입력 받아서도 만들 수 있고, 배열을 이용하는 방법은 특별하지 않다.
그렇다면 2차원 배열은 어떻게 할당해주면 될까?
2차원 배열을 할당 받는 방법은 크게 2가지로 나눌 수 있다.
첫 번째 방법은 2차원 배열을 1차원 배열의 배열로 생각해 할당한다.
우선 1차원 포인터 배열을 할당해 2차원 포인터의 주소에 넣는다.
그 다음 포인터 배열의 각 포인터에 1차원 배열을 할당해준다.
이렇게 각각의 포인터에 따로 1차원 배열을 할당해주는 것이다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
#include <stdio.h>
#include <stdlib.h>
int main() {
int rows = 3, cols = 4; // 3*4 2차원 배열
// 1. 행에 대한 포인터 배열 할당
int** arr = (int**)malloc(rows * sizeof(int*));
// 2. 각 행에 대한 메모리 할당
for (int i = 0; i < rows; i++) {
arr[i] = (int*)malloc(cols * sizeof(int));
}
for (int i = 0; i < rows; i++) {
for (int j = 0; j < cols; j++) {
arr[i][j] = i + j;
}
}
for (int i = 0; i < rows; i++) {
for (int j = 0; j < cols; j++) {
printf("%d ", arr[i][j]);
}printf("\n");
}
for (int i = 0; i < rows; i++) { //각 행에 대한 메모리 해제
free(arr[i]);
}
free(arr); //포인터 배열 해제
return 0;
}
1
2
3
0 1 2 3
1 2 3 4
2 3 4 5
이렇게 되면 free()를 arr[i]에 대해 먼저 해주어야 한다.
할당된 각 1차원 배열들을 해제하고, 그 다음에 포인터 배열에 대한 해제를 해야 한다.
이 순서가 바뀌어 arr이 먼저 해제가 되버리면 arr[i]에 대한 정보가 사라지고, free를 할 수 없다.
두 번째 방법은 1차원 배열을 2차월 배열처럼 사용하는 것이다.
하나의 큰 메모리를 할당하고, 인덱스를 계산해서 2차원 배열처럼 접근한다.
3*4 크기의 2차원 배열을 선언한다고 하면 3*4*sizeof(int) 만큼을 할당받는다.
그 다음 4단위로 계산을 하면 2차원 배열처럼 접근할 수 있다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#include <stdio.h>
#include <stdlib.h>
int main() {
int rows = 3, cols = 4; // 3*4 2차원 배열
// 1. 2차원 배열을 위한 메모리 할당
int* arr = (int*)malloc(rows * cols * sizeof(int));
for (int i = 0; i < rows; i++) {
for (int j = 0; j < cols; j++) {
arr[i * cols + j] = i + j;
}
}
for (int i = 0; i < rows; i++) {
for (int j = 0; j < cols; j++) {
printf("%d ", arr[i * cols + j]);
}
printf("\n");
}
free(arr);
return 0;
}
1
2
3
0 1 2 3
1 2 3 4
2 3 4 5
이러면 큰 메모리는 한 번만 할당해 줄 수 있고, free도 역시 한 번만 하면 된다.
배열의 요소에 접근하기 위해서는 1차원 배열이기에 arr[][] 형식이 아닌 arr[]에 수식이 들어간다.
첫 번째 방법과 두 번째 방법에는 각각 장단점이 있다.
int** arr을 이용해 2차원 배열을 1차원 배열의 배열로 다루는 방법은 각 행을 개별적으로 관리한다.
장점으로는 늘 사용하던 2차원 배열처럼 ar[i][j]로 접근 할 수 있다는 것이다.
각 행을 개별적으로 할당하기 때문에 필요하다면 특정 행의 크기를 변경해서 유연하게 사용할 수도 있다.
하지만 단점으로는 malloc과 free가 여러번 사용되기에 관리가 복잡하고, 연산 과정이 많아진다.
따로따로 할당이 되기에 각 행에 할당되는 메모리 구조가 실제로는 연속적이지 않다.
반면 int* ar은 2차원 배열을 1차원 배열로 다뤄 큰 메모리를 한 번 할당한다.
장점으로는 메모리 할당과 해제가 1번 뿐이므로 관리가 쉽다.
메모리가 한번에 할당이 되기에 연속적인 메모리가 할당이 되기에 효율성이 높다.
단점으로는 인덱싱을 계산을 해서 arr[i * width + j]처럼 복잡해져 가독성이 낮아진다.
각 행이 연속적이기에 배열의 크기에 변화가 필요할 때에 유연하게 대처하기가 어렵다.
이렇게 2차원 배열을 할당하는 방법에 알아봤고, 둘 다 장단점이 있으니 필요에 따라 사용하면 된다.
구조체 할당
구조체의 할당도 간단하다.
변수나 배열을 할당해주는 코드에서 자료형의 이름만 struct로 바꾸면 된다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#define _CRT_SECURE_NO_WARNINGS
#include <stdio.h>
#include <stdlib.h>
typedef struct {
int id;
char name[10];
} Student;
int main() {
Student* student = (Student*)malloc(sizeof(Student) * 2);
student[0].id = 1;
student[1].id = 2;
strcpy(student[0].name, "Alice");
strcpy(student[1].name, "Bob");
printf("id: %d, 이름: %s\n", student[0].id, student[0].name);
printf("id: %d, 이름: %s\n", student[1].id, student[1].name);
free(student);
return 0;
}
1
2
id: 1, 이름: Alice
id: 2, 이름: Bob
구조체 정의란 새로운 자료형을 만드는 것과 같기에 자료형의 이름만 바꾸면 되는 것이다.
또, 구조체를 받을 때 특히 sizeof()로 표현하는 것이 훨씬 편하고 정확하다.
구조체를 공부할 때 봤던 것처럼 구조체의 size는 어떤 규칙에 의해 정해지므로 직접 계산을 한다면
실수할 부분이 많기에 sizeof()를 통해 컴퓨터에게 계산을 맡기는 것이 확실하기 때문이다.
거의 모든 경우에서 할당받을 메모리의 크기는 sizeof()를 통해 표현하는 것이 좋다.
이렇게 malloc과 calloc을 이용해서 배열과 구조체까지 할당받을 수 있었다.
남은 realloc과 free 함수, 그리고 가변 인자 함수에 대해서 알아보자.