10-4.이중 포인터

10-4-가.이중 포인터

이중 포인터란 포인터 변수를 가리키는 포인터라는 뜻이며 다른 말로 하면 포인터의 포인터라고 할 수 있다. 포인터 변수도 메모리를 차지하고 있으므로 이 변수도 당연히 번지가 있다. 따라서 이 번지를 가리키는 또 다른 포인터 변수를 선언할 수 있는 것이다. 이중 포인터 변수를 선언할 때는 * 구두점을 두 번 연속해서 쓴다.

 

int **ppi;

 

이 선언에서 ppi는 정수형 대상체를 가리키는 포인터 변수의 번지를 가리키는 포인터 변수로 선언되었다. 말이 조금 꼬이는 것 같고 복잡해 보이는데 어째서 저런 변수를 선언할 수 있는지 차근 차근히 풀어 보도록 하자. 다음 두 명제는 앞에서 이미 공부했던 것들인데 이중 포인터를 이해하기 위해 다시 정리해 보자.

 

T *형은 하나의 타입으로 인정된다.

T 변수를 선언할 있으면 T *형도 항상 선언할 있다.

 

정수형 포인터 변수는 다음과 같이 선언한다.

 

int *pi;

 

이 선언문에서 int *라는 표현이 "정수형 포인터"라는 뜻으로 그 자체가 하나의 타입이다. 따라서 다음과 같이 괄호로 묶으면 좀 더 읽기 쉬워지고 뜻이 분명해질 것이다.

 

(int *) pi;

 

두 번째 명제에 의해 T형에 대해 항상 T *형이 가능하므로 int *형에 대한 포인터형을 만들 수 있다. int *형 변수를 가리키는 변수 ppi를 선언하면 다음과 같아진다.

 

(int *) *ppi;

 

이 선언문에서 괄호를 제거하면 최초의 이중 포인터 선언문인 int **ppi;가 된다. 물론 여기서 사용한 괄호는 어디까지나 설명을 위해 쓴 것이지 실제로 타입에 괄호를 쓰면 컴파일 에러로 처리된다. 하지만 typedef문으로 int *형을 별도의 타입으로 정의한 후 이 타입의 변수를 선언할 수는 있다.

 

typedef int *PINT;

PINT pi;

 

보다시피 int *형을 PINT라는 사용자 정의 타입으로 정의하였고 이 타입의 변수 pi를 선언할 수 있다. 포인터 변수 pi를 가리킬 수 있는 포인터 변수를 선언하면 다음과 같다.

 

PINT *ppi;

 

이 선언문에서 PINT를 사용자 정의형으로 풀어 쓰면 int **ppi;가 되는 것이다. 같은 원리로 3중 포인터는 int ***pppi;와 같이 선언하면 되고 5중 포인터나 8중 포인터도 그 수만큼 *를 계속 붙이면 된다. 3중 포인터 이상은 현실적으로 거의 사용되지 않지만 이중 포인터와 원리는 동일하므로 이중 포인터만 이해하면 된다. 이중 포인터는 포인터 변수를 가리키는 변수이므로 이 변수에는 포인터의 포인터값을 대입해야 한다. 즉, 포인터 변수에 &연산자를 붙인 값이면 이중 포인터에 대입할 수 있다. 다음 예제는 이중 포인터의 동작 원리를 보여주는 가장 간단한 예제이다.

 

: dblPointer

#include <Turboc.h>

 

void main()

{

     int i;

     int *pi;

     int **ppi;

 

     i=1234;

     pi=&i;

     ppi=&pi;

 

     printf("%d\n",**ppi);

}

 

정수형 변수 i에 1234값을 대입해 놓고 이중 포인터로 이 값을 읽는 시범을 보인다. 정수형 포인터 pi가 i의 번지를 가지고 정수형 이중 포인터 변수 ppi에는 pi의 번지를 대입해 놓고 **ppi값을 읽으면 결국 i의 값이 읽혀진다. 이 프로그램에서 각 변수들이 서로를 가리키는 모양은 다음과 같을 것이다.

i는 일반 변수이므로 메모리상에 4바이트를 차지하고 1234가 대입되었다. 정수형 포인터 pi도 일종의 변수이므로 메모리상에 할당되며 그 초기값으로 &i, 즉 i의 번지를 대입받았다. 이 상태에서 pi가 i를 가리키고 있으므로 *pi 연산문으로 i값을 읽을 수 있다. 여기까지는 앞에서 이미 살펴 본 것들이다.

포인터 변수 pi도 일종의 변수이므로 분명히 메모리에 할당될 것이고 따라서 번지를 가지고 있다. pi가 할당되어 있는 번지값인 &pi를 이중 포인터 변수 ppi에 대입했다. 그래서 ppi는 pi를 가리키고 pi는 다시 i를 가리키고 있는 것이다. 이 상태에서 **ppi 연산문으로 값을 읽으면 *(*ppi)=*(pi)=i가 되므로 결국 출력되는 값은 i인 것이다.

ppi가 가리키는 곳에 pi가 있으며 pi가 가리키는 곳에는 i가 있으므로 ppi에 대해 *연산자를 두 번 적용하면 결국 i값이 읽혀진다. 이 예제의 상황에서 다음 수식들은 모두 동등한 대상을 나타낸다.

 

i=*pi=**ppi

&i=pi=*ppi

*&i=*&*pi=*&**ppi

 

* 연산자와 & 연산자는 서로 반대되는 동작을 하는데 이 두 연산자에 의해 가리키고 끄집어 내오다 보면 동등한 수식이 여러 개 생길 수 있다. 그렇다고 해서 &&i=&pi=ppi라는 등식은 성립하지 않는데 &연산자를 두 번 쓰는 것은 적법하지 않다. 왜냐하면 &연산자의 피연산자는 메모리상의 실제 번지를 점유하고 있는 좌변값(lvalue)이어야 하는데 &i는 i가 저장된 번지를 나타내는 포인터 상수일 뿐 좌변값이 아니기 때문이다.

다음 예제는 이중 포인터의 전형적인 활용예인데 포인터를 참조 호출로 전달하여 함수가 포인터를 변경할 수 있도록 한다. main에서 이름을 입력받는 함수를 호출하는데 이 이름의 길이가 DB나 사용자로부터 입력되어 실제 입력받아 보기 전에는 얼마일지를 알 수 없다고 하자. 이럴 경우 함수가 필요한만큼 메모리를 할당해서 할당한 번지를 리턴하도록 해야 한다.

 

: FuncAlloc

#include <Turboc.h>

 

void InputName(char **pName)

{

     *pName=(char *)malloc(12);

     strcpy(*pName,"Cabin");

}

 

void main()

{

     char *Name;

 

     InputName(&Name);

     printf("이름은 %s입니다\n",Name);

     free(Name);

}

 

main에서 char *형의 변수 Name을 선언하고 이 포인터 변수의 번지, 즉 char **형의 이중 포인터를 InputName 함수로 전달했으며 이 함수는 이중 포인터를 형식 인수 pName으로 대입받는다. Name은 함수 내부에서 값이 결정되는 출력용 인수이기 때문에 호출원에서 초기화하지 않아도 상관없다. InputName 함수는 필요한만큼(예제에서는 12로 가정) 동적으로 메모리를 할당하여 할당된 번지를 pName이 가리키는 번지인 *pName에 대입했다. 여기서 *pName이라는 표현식은 곧 main에서 InputName으로 전달한 실인수 Name을 의미한다. 그리고 할당된 번지에 어떤 문자열을 복사했다.

결국 InputName 함수는 main의 Name 포인터 변수를 참조 호출로 전달받아 Name에 직접 메모리를 할당하고 이 번지에 scanf로 입력받은 이름까지 복사한 것이다. InputName이 리턴되었을 때 Name은 12바이트 길이로 할당된 번지를 가리키며 이 안에는 입력된 이름까지 들어 있으므로 printf로 출력할 수 있고 다 사용한 후 free로 해제하면 된다. 이 문제를 잘못 생각하면 다음과 같이 InputName 함수가 char *형을 받도록 작성할 수도 있다.

 

: FuncAlloc2

#include <Turboc.h>

 

void InputName(char *pName)

{

     pName=(char *)malloc(12);

     strcpy(pName,"Cabin");

}

 

void main()

{

     char *Name;

 

     InputName(Name);

     printf("이름은 %s입니다\n",Name);

     free(Name);

}

 

언뜻 보기에는 이 코드가 맞는 것 같지만 실제로 컴파일해 보면 경고가 하나 발생하며 실행하면 제대로 동작하지도 않을 뿐더러 죽어 버리기까지 한다. 왜 그런지 다음 그림을 보자.

main에서 Name을 선언하고 초기화되지도 않은 Name의 값(비록 그 값이 번지라 하더라도 어쨌든 값이다.)을 InputName의 pName으로 전달했다. 이 함수는 pName에 메모리를 할당하고 이름 문자열을 복사해 넣지만 pName은 함수의 지역변수일 뿐이지 호출원의 실인수 Name과는 아무런 상관이 없다. 함수가 char *의 값을 전달받으면 이 번지가 가리키는 내용을 변경할 수는 있지만 포인터 자체를 변경해서 호출원으로 돌려줄 수는 없다.

이 코드대로라면 pName에 메모리가 할당되고 이름도 복사되지만 그 결과가 main함수의 Name까지는 전달되지 않는다. main의 Name은 여전히 쓰레기값을 가지고 있으며 이 번지를 잘못 읽거나 할당되지도 않은 영역을 해제하려고 시도하면 죽어 버릴 수도 있다. 뿐만 아니라 지역변수 pName은 함수가 리턴되기 전에 사라지므로 할당된 메모리의 진입점을 잃어 버려 더 이상 이 메모리를 읽을 수 없고 해제할 수도 없는 상태가 되어 버린다.

6장에서 배운 바에 의하면 인수 X의 값을 함수 내부에서 변경하려면 X의 포인터를 넘기는 참조 호출을 해야 한다. 그러므로 InputName에서 char *형의 인수 Name을 변경할 수 있도록 하려면 char *의 포인터인 char **형을 넘겨야 하고 InputName 함수에서는 *pName으로 실인수를 참조해야 하는 것이다. 만약 이 두 예제가 잘 이해가 되지 않는다면 6장의 값 호출, 참조 호출 예제로 돌아가 좀 더 단순한 타입인 정수형의 경우부터 복습한 후 다시 읽어 보아라. 그리고 메모리 안에서 어떤 일들이 일어나는지 잘 상상해 보아라.