[CS] TIL 18. malloc과 포인터 복습, 배열의 크기 조정하기


  1. 포인터를 초기화시키지 않고 값을 저장하면 어떤 오류가 발생할 수 있을까?
  2. 이미 할당된 메모리의 크기를 조절할 때 임시 메모리를 새로 할당해줘야 하는 이유는 무엇일까?


boostcourse모두를 위한 컴퓨터 과학 (CS50 2019) 강의를 듣고 정리한 필기입니다. 😀


1. malloc과 포인터 복습

int main(void)
{
    int *x;
    int *y;

    x = malloc(sizeof(int));

    *x = 42;
    *y = 13;
}

지난 강의에서 우리는 C 문법을 배웠다. 포인터로 메모리상의 실제 주소를 확인할 수 있었으며, * 연산자나 malloc, free 같은 함수로 메모리를 관리하는 방법을 배웠다. 잠깐의 복습을 위해서 버그가 있는 위 코드를 보자.

int *x;
int *y;

main 함수 안의 첫 두 줄은 정수를 가리키는 포인터를 만들고 그 변수를 xy라고 부르는 코드이다.

x = malloc(sizeof(int));

위 코드는 malloc 함수를 통해서 int 크기의 메모리를 만들어 변수 x에 할당하는 것이다. malloc은 메모리를 할당하는 주는 함수로써 할당받고 싶은 크기를 유일한 인자로 받는다. 정수형의 크기를 잊었어도 sixeof 연산자를 사용하면 대부분 4를 반환하게 된다. 그러면 메모리를 할당하고 메모리 영역의 첫 주소를 반환할 것이다. 0x로 시작하는 4바이트가 있는 주소이며, 그 주소는 변수 x에 저장한다.

보통 정수를 할 당해야 할 때는 int n;으로 만들지만, 주소와 메모리를 할당할 수 있기 때문에 적용한 것이다.

*x = 42;
*y = 13;

xy에 저장된 주소로 가서 각각 42와 13을 저장하는 코드이다. 변수명 앞에 있는 *는 해당하는 주소로 가는 것으로 역참조 연산자라고 한다. *x = 42;malloce이 할당해준 4byte 크기의 메모리에 가서 42를 넣으려는 것이기 때문에 버그가 없다. 하지만 *y = 13;y라는 변수는 포인터로만 선언됐을 뿐, 해당 변수를 위한 공간이 할당되지 않았다. 즉, 쓰레깃값이 있다고 가정해야 할 것이다. 이렇게 없거나 잘못된 주소에 접근하면 코드의 메모리 문제나 세그멘테이션 오류 등의 코드가 발생한다.

y에 대한 코드를 아래처럼 변경해보자.

y = x;

*y = 13;

y = x;라는 코드는 y에 x가 가리키는 곳과 동일한 곳을 저장하게 된다. 이제 y를 위한 메모리 공간이 생겼고, ` *y = 13;`라는 코드를 통해 13을 저장하게 될 것이다. 이렇게 되면 42는 13으로 덮어버린다.



2. 배열의 크기 조정하기

2.1 그림

배열은 크기를 미리 지정해줘야 한다. 그래서 3이나 3이 포함된 변수를 하드코딩해야 했다. 하지만 만약에 크기가 3인 베열을 가지고 네번째 값을 저장하려면 어떻게 해야할까?

단순하게 생각하면 현재 배열이 저장되어 있는 메모리 위치의 바로 옆에 일정 크기의 메모리를 덧붙인다고 생각하겠지만, 좋은 생각은 아니다. 이 방법은 옆에 덧붙인 메모리에 다른 데이터가 저장되어 있을 확률이 높기 때문이다.

title

위 사진을 보고 있으면 1, 2, 3 이후에 숫자 4를 넣을 공간이 없기 때문에 배열을 늘릴 수 없다고 생각하게 될 것이다. 다른 메모리로 이동을 시켜야 할까? 배열의 크기를 늘리기 위해서 이동을 시키게 되면 다른 배열 안에 있는 것도 옮겨야 하기 때문에 시간이 많이 걸릴 것이다.

title

원래 주어진 위치 주변에 있는 메모리에 데이터가 저장되어있다면 배열의 크기는 바꿀 수 없다. 때문에 배열을 움직이거나, 새 공간에 복사를 해야할 것인데, 위 사진처럼 1,2, 3을 아래쪽으로 옮기면 4을 넣을 수 있는 공간이 생길 것이다. 1, 2, 3. 모든 데이터를 복사했다면, 이전에 사용된 메모리는 버리거나 free를 실행하면 된다. 그리고 4를 추가하면 크기가 3인 배열을 4로 늘리고, 네번째 값을 저장한 것이 된다. 하지만 이게 꼭 최선의 방법은 아니다.

이러한 작의 실행시간은 O(n)이 될 것이다. 처음 사물함의 크기만큼 데이터를 옮겨 넣기 위한 추가 과정이 필요하기 때문이다. 사물함이 n개면, n번의 복사 작업이 필요하다는 말이다.

2.2 코드

2.2.1 malloc 활용

#include <stdio.h>

int main (void)
{
    // 3개의 정수를 임의로 만듦
    int list[3];
    
    // 이 list를 세가지 값을 수동을 초기화 한다.(하드코딩)
    list[0] = 1;
    list[1] = 2;
    list[2] = 3;
    
    // 위 세 값이 잘 들어갔는지 확인해보기
    for (int i - 0; i < 3; i++)
    {
        printf("%i\n", list[i]);
    }
}

위 코드는 길이가 3인 list라는 배열에 각각 1, 2, 3을 들어가게 하는 프로그램이다. 이 코드에는 근본적인 문제가 존재한다. 직접 이 배열의 크기를 타이핑해서 하드코딩을 했기 때문에 네 번째 숫자를 넣기 위해서는 프로그램의 코드를 직접 고쳐야 한다. (동적이지 않다.)

#include <stdio.h>

int main (void)
{
    // 3개의 정수를 임의로 만듦
    int *list = malloc(3 * sizeof(int));
    
    // 이 list를 세가지 값을 수동을 초기화 한다.(하드코딩)
    list[0] = 1;
    list[1] = 2;
    list[2] = 3;
    
    // 위 세 값이 잘 들어갔는지 확인해보기
    for (int i - 0; i < 3; i++)
    {
        printf("%i\n", list[i]);
    }
}

비동기적인 프로그램을 동적으로 만들기 위해서 메모리를 동적으로 배정할 수 있는 함수인 malloc을 사용했다. malloc는 배정하고 싶은 크기를 코드에 적을 필요가 없으며, 메모리를 할당하고 그 메모리 덩어리의 첫 번째 주소를 반환하게 된다. 따라서 malloc을 이용해 list라는 정수에 대한 포인터를 만들 수 있다는 것이다. int *list = malloc(3 * sizeof(int));에서 3에 정수의 크기를 곱한 이유는 3개의 정수에 맞는 메모리를 받기 위해서이다. 이 과정을 거치면 list는 메모리 덩어리를 해당 주소에 담고 있는 변수가 된다. 여기서 C의 멋진 점은 대괄호 기호를 그대로 사용할 수 있다는 점이다. 즉, list라는 변수는 포인터 변수가 되었지만, 단순 배열처럼 list[1]이라는 형태로 접근할 수 있다는 말이다.

우리가 필요한 것은 4바이트이다. list[0]는 0번째 바이트가 나온다. list[1]는 두 번째 바이트가 나오는 것이 아니라, 4바이트 뒤가 된다. list[2]는 세 번 째 바이트가 아니라 8바이트 뒤가 된다. 이렇게 되는 이유는 list를 선언할 때 3 * sizeof(int)를 해줌으로써 12바이트를 할당받았기 때문이다. ( sixeof 연산자를 사용하면 대부분 4를 반환하게 된다는 것을 기억하자. 4+4+4는 12이다.) 그리고 대괄호는 정확한 메모리 주소로 가서 정수 3개를 알맞게 넣어준다.

Q1. 왜 포인터를 정수 배열이 아니라 정수에 할당했을까?

▶ 이 맥락에서는 배열과 포인터는 어떤 의미에서는 동일하다. 포인터는 메모리의 주소이고, 배열은 메모리의 덩어리이다. 처음 배울 때는 메모리 덩어리를 배열이라는 이름으로 사용했지만, 조금 더 일반적으로 배열은 대괄호를 통해 나타낼 수 있는 메모리 덩어리이다. 하지만 이제 어느 정도 배웠고, 원하는 만큼의 메모리를 할당할 수 있기 때문에 이 두 가지 개념을 바꿔서 사용할 수 있다. 좀 더 자세히 들어가면 다른 점이 존재하겠지만, 같은 결과를 낼 수 있을 것이다. 단지 이번에는 malloc를 사용해 코드가 루프를 돌며 계속해서 더 많은 메모리를 필요할 때마다 할당하는 예를 상상할 수 있다.

#include <stdio.h>
#include <stdlib.h>

int main (void)
{
    // 3개의 정수를 임의로 만듦
    int *list = malloc(3 * sizeof(int));    
    
    // 포인터가 잘 선언되었는지 확인
    if(list == NULL)
    {
        return 1;
    }
    
    // 이 list를 세가지 값을 수동을 초기화 한다.(하드코딩)
    list[0] = 1;
    list[1] = 2;
    list[2] = 3;
    
    // 위 세 값이 잘 들어갔는지 확인해보기
    for (int i - 0; i < 3; i++)
    {
        printf("%i\n", list[i]);
    }
}

malloc은 가끔 메모리를 다 잡아먹기 때문에 우리는 몇 가지 안전성 검사를 해봐야 한다. Mac이나 PC, 혹은 클라우드 계정에 메모리가 부족해진다는 것은 결코 좋은 코드라고 할 수 없다. 그러니, 가장 좋은 방법은 메모리 할당을 할 때마다 NULL 값이 반환되는지를 확인해보는 것이다.

#include <stdio.h>
#include <stdlib.h>

int main (void)
{
    // 3개의 정수를 임의로 만듦
    int *list = malloc(3 * sizeof(int));    
    
    // 포인터가 잘 선언되었는지 확인
    if(list == NULL)
    {
        return 1;
    }
    
    // 이 list를 세가지 값을 수동을 초기화 한다.(하드코딩)
    list[0] = 1;
    list[1] = 2;
    list[2] = 3;
    
    // tmp 포인터에 메모리를 할당하고 list의 값 복사
    int *tmp = malloc(list, 4 * sizeof(int));
    
    if (tmp == NULL)
    {
        return 1;
    }
    
    // list의 값을 tmp로 복사
    for (int i = 0; i < 3; i++)
    {
        tmp[i] = list[i];
    }
    
    // tmp배열의 네 번째 값도 저장
    tmp[3] = 4;
    
    // list의 메모리를 초기화
    free(list);
    
    // list가 tmp와 같은 곳을 가리키도록 지정
    list = tmp;
    
    // 위 세 값이 잘 들어갔는지 확인해보기
    for (int i - 0; i < 4; i++)
    {
        printf("%i\n", list[i]);
    }
    
    // list의 메모리 초기화
    free(list);
}

이제 배열의 크기를 바꿔보자. 예를 들면 for문 위에서 int *tmp= malloc(4 * sizeof(int));라는 코드를 적어 3이 아닌 크기가 3인 정수 크기만큼 가지고 온다고 하자. 그리고 메모리가 부족해서 tmp == null이 되면 프로그램은 끝날 것이다. 더는 진행할 수 없는 것이다. for문을 이용해 list에 있는 데이터를 tmp로 복사하고, tmp[3] = 4; 까지 하면, 기존 배열에서 새로운 배열로 옮기는 물리적 개념을 구현한 것이다. list는 이제 쓰레깃값이 되었으니 free를 통해서 메모리 해제를 해줘야 한다.

그리고 원래 포인터 이름을 그대로 쓰기 위해서 list = tmp을 통해 업데이트시키면서 이름을 바꾼다. tmp라는 이름을 쓰기에는 적절하지 못하기 때문이다.

위 프로그램을 컴파일링 후 실행해보면 우리가 원하는 값이 출력되겠지만, 이 프로그램은 너무 지저분한 느낌이다. 만약, 이 프로그램을 반쯤 짜다가 세 개가 아닌 네 개의 정수를 저장하고 싶어지면, 그냥 이전 코드를 수정하거나 다시 짜면 될 것이다. 그 때문에 이것은 그냥 누군가 정수를 새로 받아와서 메모리가 더 필요해지면 이런 방식으로 malloc을 이용하면 되는 시범이었다고 생각하자.

##

2.2.2 realloc 활용

// list의 값을 tmp로 복사
for (int i = 0; i < 3; i++)
{
    tmp[i] = list[i];
}

// tmp배열의 네 번째 값도 저장
// tmp[3] = 4;
    
// list의 메모리를 초기화
free(list);

위 코드 중에서 tmp[3] = 4;를 제외한 나머지는 realloc를 활용해 합칠 수 있다.

#include <stdio.h>
#include <stdlib.h>

int main(void)
{
    int *list = malloc(3 * sizeof(int));
    if (list == NULL)
    {
        return 1;
    }

    list[0] = 1;
    list[1] = 2;
    list[2] = 3;

    // tmp 포인터에 메모리를 할당하고 list의 값 복사
    int *tmp = realloc(list, 4 * sizeof(int));
    if (tmp == NULL)
    {
        return 1;
    }

    // list가 tmp와 같은 곳을 가리키도록 지정
    list = tmp;

    // 새로운 list의 네 번째 값 저장
    list[3] = 4;

    // list의 값 확인
    for (int i = 0; i < 4; i++)
    {
        printf("%i\n", list[i]);
    }

    //list 의 메모리 초기화
    free(list);
}

realloc는 메모리를 새로 할당한다는 뜻이다. 우리는 여기서 list를 새로 할당할 것이다. 더해서 우리는 크기가 3이 아니라 4로 늘려야 하므로 int *tmp = realloc(list, 4 * sizeof(int)); 와 같은 코드를 쓰게 된다.

stdib.h에 들어 있는 또 다른 함수 realloc은 이미 할당받은 기존 메모리 덩어리를 새롭게 가져오고, 원래보다 크든 작든 새롭게 설정된 크기로 바꾸는 작업을 한다. realloc이 기존의 배열에서 새로운 배열로 데이터를 복사하기 때문에, 우리가 할 일은 무언가 잘못되지는 않았는지, 메모리는 충분한지를 체크하고 새로운 값을 저장하기만 하면 된다.




© 2020. by RIVER

Powered by RIVER