메뉴 바로가기 검색 및 카테고리 바로가기 본문 바로가기

한빛출판네트워크

IT/모바일

배열과 포인터, 애증은 없다

한빛미디어

|

2006-06-09

|

by HANBIT

12,598

제공: 한빛미디어 네트워크 기사
저자: 김대곤

시작하며

배열과 포인터가 같은 것이냐 다른 것이냐에 대한 여러가지 설명이 있습니다. 사실 아주 정확한 내용은 단순히 C 프로그램에 있는 것은 아닙니다. 그 프로그램을 기계어로 옮겨주는 컴파일러, 실제 컴퓨터에 따라 조금씩 다르게 C 프로그램은 해석되기도 합니다. 이 기사에서는 프로그래머에게 보이는 배열과 포인터의 관계를 살펴보았습니다.

실제 배열과 포인터 변수의 단순 비교는 좀 억울한 측면이 있습니다. 왜냐하면, 배열은 포인터 변수와 같은 것처럼 보이지만, 실제로는 두 가지로 구성되어 있기 때문입니다. 배열은 실제 공간과 그 공간을 가르키는 주소을 가지고 있는 변수을 함께 지칭하기 때문입니다.

자, 그럼 이제 슬슬 이야기를 시작해 보겠습니다.

메모리의 생명주기

C 프로그램에서 메모리는 두 가지로 나누어진다. 첫번째는 스택에 기반을 둔 메모리로서 일반 변수 및 배열이 여기에 해당된다. 두 번째 메모리는 HEAP에 기반을 둔 메모리로서 malloc을 통해서 할당된 메모리를 말한다. 이 두가지 메모리의 차이는 생명주기에 있다. HEAP에 기반을 둔 메모리가 명시적인 free 문장만으로 컴퓨터로 반환된다면, 스택에 기반을 둔 메모리는 사용범위에 따라 생명주기가 결정된다.

두 가지 메모리의 차이를 보여주는 프로그램을 살펴보자. 메인 함수에서는 포인터 변수를 선언하고, 메모리 할당 및 초기화을 위해서는 보조 함수(foo 와 goo)에서 호출하고 나서, 그 값을 출력하였다.

/*
* Chapter 7. Stack-based & Heap-based memory.
*/

#include
#include

#define N 10

int *foo();
int *goo();

int main() {
    int *x;
    int i;

    // Heap-based memory
    printf("Heap-based memoryn");
    x = foo();
    for ( i=0 ; i < N ; i++ ) {
        printf("x[%d]=%dn", i, x[i]);
    }

    // Stack-based memory
    printf("Stack-based memoryn");
    x = goo();
    for ( i=0 ; i < N ; i++ ) {
        printf("x[%d]=%dn", i, x[i]);
    }

}  

int *foo() {
    int *x;
    int i;
    x = (int *)malloc(sizeof(int)*N);
    // Initialization.
    for ( i=0 ; i < N ; i++ ) {
        x[i] = N-i;
    }  
    return x;
}

int *goo() {
    int x[N];
    int i;
    // Initialization.
    for ( i=0 ; i < N ; i++ ) {
        x[i] = N-i;
    }  
    return x;
}

foo 함수는 malloc을 이용하여 메모리를 할당하였고, goo 함수에서는 배열 선언을 이용하여 메모리를 할당하였다. 두 리스트는 같은 값들을 가지도록 초기화 되었다. 반면에, 메인 함수에서 출력된 값은 아주 상이하다.

bono> a.out
Heap-based memory
x[0]=10
x[1]=9
x[2]=8
x[3]=7
x[4]=6
x[5]=5
x[6]=4
x[7]=3
x[8]=2
x[9]=1
Stack-based memory
x[0]=10
x[1]=5117659
x[2]=6043104
x[3]=134514126
x[4]=-1081427972
x[5]=-1081427980
x[6]=6041588
x[7]=-1081427928
x[8]=134513800
x[9]=134514126

foo 함수에서는 할당된 메모리(Heap-based)는 계속 사용할 수 있는 메모리인 반면에 goo에서 할당된 메모리(Stack-based)는 함수의 종료와 함께 더 이상 사용될 수가 없다. 실제로 gcc는 이 프로그램을 컴파일할 때 다음과 같은 에러 메시지를 출력하였다.

7-1.c: In function ‘goo’
7-1.c:51: warning: function returns address of local variable

하지만 이 메세지가 배열과 포인터 변수의 근본적인 차이를 의미하지는 않는다. 왜냐하면, goo 함수에서 포인터 변수에 선언하고, 배열의 주소를 포인터 변수에 대입하고, 그 포인터 변수를 반환하면, 위의 메세지는 출력되지 않는다.

다음 프로그램은 스택에 기반을 둔 메모리가 어떻게 작동하는지를 보여주고, 왜 스택에 기반을 두었다고 하는지 알려주는 예제이다. 프로그램의 메인 함수에서 배열을 선언하고, 그 다음에 초기하하고, 함수를 호출하여 그 배열의 값을 출력하는 프로그램이다.

/*
* Chapter 7. Stack-based & Heap-based memory.
*/

#include
#include

#define N 10

void goo();

int main() {
    int x[N];
    int i;

    // Initialization.
    for ( i=0 ; i < N ; i++ ) {
        x[i] = N-i;
    }  

    goo(x);
}  

void goo(int *x) {
    int i;
    // print an array.
    for ( i=0 ; i < N ; i++ ) {
        printf("x[%d]=%dn", i, x[i]);
    }
}

위의 프로그램은 프로그래머가 예상한 것처럼 제대로 작동한다. 즉, 배열이 선언된 함수 밖에서도 배열은 여전히 유효하다. 실제로 프로그램이 컴퓨터 상에서 실행되는 과정은 스택을 기반으로 하고 있다. 즉, 첫번째 예제의 경우, 컴퓨터는 메인 함수를 처음으로 스택에 입력한다. 그리고 나서 foo 함수를 호출할 때, foo 함수를 스택이 입력하고, 함수가 리턴되는 순간은 foo 함수를 스택에서 제거한다. 다시 goo 함수를 스택에 입력하고 goo 함수를 스택에서 제거한다. 이 때 goo에서 선언된 모든 변수 및 배열도 같이 제거된다. 즉, goo 함수가 스택에서 사라지는 순간, 배열의 메모리도 더 이상 사용하면 안되는 존재가 되는 것이다. (위의 출력이 말해주듯 실제로는 사용할 수 있으나 잘못된 값을 참조하는 것이다) 반면에, 바로 위에서 살펴본 예제에서는 배열을 출력할 당시에, 스택에는 메인 함수와 goo 함수가 존재한다. 그러므로 메인 함수에서 선언된 배열도 여전히 사용할 수 있다. 다음 그림은 배열이 출력될 당시의 스택의 모습을 보여준다.

즉, 배열이 선언된 함수가 컴퓨터 상의 스택에서 존재할 때에서 그 배열은 사용 가능하며, 그렇지 않은 경우에는 사용해선 안 된다. 반면에, malloc으로 할당된 메모리는 free을 이용하여 명시적으로 반환되는 않는 한 컴퓨터 상의 스택과는 무관하게 항상 존재하는 메모리이다. 더 이상 접근할 수 있는 Heap-based 메모리를 C 프로그램에서는 메모리 릭(Leak)이라고 지칭한다.


배열과 포인터

배열과 포인터 모두 각 타입 마다 존재한다. 예를 들어, int 형에는 int 배열과 int 포인터 변수가 존재한다. 배열은 [타입명 : 배열명 : 배열의 크기]로 선언된다. 예를 들어, 사이즈가 10인 정수 배열 arr은 다음과 같이 정의 된다.

int arr[10];

일반적으로 배열의 사이즈는 컴파일시 알려진 상수이여야 한다는 것으로 알려져 있으나, 실제로는 변수가 배열의 사이즈로 이용되기도 한다. 예를 들면, 다음의 선언도 가능한다.

int main(int argc, char **argv) {
   int arr[argc*10];
}

포인터 변수는 [타입명 : * : 변수명]으로 선언된다. 정수형의 포인터 변수는 다음과 같이 선언된다.

int *arr;

여기서 한 가지 중요한 차이점을 알 수 있는데, 포인터 변수는 크기를 필요로 하지 않는다는 사실이다. 배열의 경우엔 반드시 크기를 필요로 하지만 포인터 변수는 그렇지 않다. 배열의 선언을 통해서 생성되는 것은 주소을 위한 공간과 실제 값을 위한 공간이 생성되는 반면에, 포인터 변수의 선언은 전자만을 만들어 낸다. 아래의 그림은 이러한 차이를 그림 상으로 보여준다. 동그라미는 주소를 위한 공간이고, 박스는 정수 값을 위한 공간이다.

또 다른 차이점은 위 그림의 화살표이다. 배열의 경우엔 선언시에 생성된 화살표을 변경할 수 없다. 즉, 배열명이 가르키고 (또는 가지고) 있는 주소는 변경할 수 없다. 반면에, 포인터 변수의 값(가르키고 있는 주소)는 변경할 수 있다. 또한, 배열의 주소를 포인터 변수에 입력할 수도 있다. 배열명 변수가 오직 읽을 수만 있는 Read-Only 라면, 포인터 변수는 Read-Write가 가능한 변수이다. 배열이 가르키고 있는 메모리는 스택에 기반을 둔 메모리이며, 포인터 변수는 스택의 기반을 둔 메모리를 가르킬 수도 있고, Heap에 기반을 둔 메모리을 가르킬 수도 있다.

이러한 차이에도 불구하고, 실제 값을 가르키는 표현은 차이가 나지 않는다. 예를 들면, 다음 두 가지 표현은 같은 의미를 지니고 있다.

arr[5];
*(arr+5);

왜냐하면, 실제적인 의미에서는 단 하나의 표현이 존재하기 때문이다. arr[5]는 컴파일러에 의해 기계적으로 *(arr+5)로 변환된다. 다음 프로그램은 기계적이라는 의미를 아주 잘 보여주고 있다.

#include

int main() {

    int arr[10];
    int i;

    for ( i=0 ; i < 10 ; i++ ) {
        arr[i] = i;
    }

    printf("arr[5]=%dn", arr[5]);
    printf("*(arr+5)=%dn", *(arr+5));
    printf("5[arr]=%dn", 5[arr]);

}

위의 프로그램은 C 컴파일러에 의해 에러나 경고 없이 컴파일되며, 실제로 다음과 같은 값을 출력한다.

bono> a.out
arr[5]=5
*(arr+5)=5
5[arr]=5

마지막 라인을 살펴 보면, 5[arr]이 arr[5]와 같은 의미로 해석되었음을 보여준다. 컴파일러는 5[arr]를 기계적으로 *(5+arr)로 변환했고, 5+arr은 arr+5 와 마찬가지로 arr가 가르키는 주소에서 5 번 진행한 후에 그 주소를 반환했고, 그 주소가 가르키는 곳에서 가서 값을 읽어서 출력했다.

정리해 보면, 배열은 값을 바꿀 수 없는 포인터 변수라 할 수 있고, 가르키고 있는 메모리는 스택에 기반을 둔 메모리이다. 반면, 포인터 변수는 값을 바꿀 수 있고, 스택에 기반을 둔 메모리나 Heap에 기반을 둔 메모리의 주소를 모두 저장할 수 있는 변수이다.


마치며

여러 가지 포인터 변수 중에서 가장 에러가 발생하기 쉬운 것은 char 포인터일 것입니다. 왜냐하며, 실제 저장되는 값도 NULL이 될 수 있고, 포인터 변수 자체도 NULL을 가지고 있을 수 있기 때문일 것입니다. 이러한 에러를 피할 수 있는 방법에 대해서는 다음 기사에서 찾아뵙도록 하겠습니다.
TAG :
댓글 입력
자료실