11-2-다.포인터와 배열

포인터와 배열은 비슷한 것 같으면서도 달라 무척 헷갈리는데 여기서 이 둘의 개념을 좀 정리해 보도록 하자. 먼저 가장 쉬운 다음 선언문을 보자.

 

int i;

 

이 선언문은 정수형 변수를 i라는 이름으로 선언하는데 컴파일러는 i를 위해 4바이트를 할당한다. 너무 너무 쉬운 문장이라 한 눈 감고 읽어도 이해할 수 있다. 다음 두 선언문도 지금까지 여러 번 학습했던 것이므로 의미를 쉽게 알 수 있을 것이다.

 

int ar[n];

int *pi;

 

ar[n]은 정수형 변수 n개를 모아 놓은 정수형 배열이다. 여기서 n은 변수가 아니라 임의의 상수를 의미하며 필요한만큼의 크기를 줄 수 있다. 이 선언에 의해 컴파일러는 정수형 변수 n개를 저장할 수 있는 연속적인 메모리를 할당하며 n이 5라고 할 때 sizeof(ar)은 20이 될 것이다. ar이라는 배열의 이름은 배열의 시작 번지를 가리키는 주소값으로 평가된다.

pi는 정수형 변수 하나의 위치를 가리킬 수 있는 포인터이며 pi 자체는 하나의 포인터 변수일 뿐이므로 컴파일러가 pi를 위해 할당하는 메모리는 항상 4바이트이다. sizeof(pi)는 4가 된다. pi는 &i같은 정수형 변수의 주소값을 대입받는다. 그러나 실행중에 동적으로 메모리를 할당하면 pi로도 정수형 배열을 가리킬 수 있다.

 

pi=(int *)malloc(n*sizeof(int));

 

malloc 함수는 정수형 변수 n개를 저장할만큼의 메모리를 할당한 후 그 시작 번지를 리턴하며 이 번지를 pi로 대입받았다. 이렇게 되면 pi는 ar과 똑같은 자격을 가지며 정수형 배열처럼 행세할 수 있다. pi나 ar이나 둘 다 배열의 선두 번지를 가리키며 ar[2]로 2번째 요소를 읽을 수 있듯이 pi[2]로도 2번째 요소를 읽을 수 있다. 설사 pi가 동적 할당한 메모리가 아닌 단순 정수형 변수의 번지 &i를 대입받았더라도 pi는 개념적인 배열로 볼 수 있다.

왜냐하면 pi++로 4바이트 뒤쪽으로 이동할 수 있으며 pi[2]라는 표현식도 항상 가능하기 때문이다. 물론 이 상태에서 pi가 제대로 된 배열로 동작하려면 i 다음의 요소도 정수형 변수여야 한다는 논리적 제약이 있지만 적어도 물리적으로 pi 포인터는 정수형 배열을 가리키고 있는 것이다. 설사 그 길이가 1에 불과하더라도 말이다.

보다시피 정수형 배열 ar과 정수형 포인터 pi는 공통점이 많다. 일단 동일 타입의 변수 집합을 다룰 수 있다는 면에서 기능적으로 같고 둘 다 범위 점검을 할 수 없다는 제약도 동일하다. 형식적으로 둘 다 무한 길이를 다룰 수 있지만 배열은 선언할 때 지정한 크기만큼, 포인터는 할당할 때 지정한 크기만큼의 실제 크기를 가지고 있으므로 개발자가 이 크기를 넘지 않도록 알아서 조심해야 한다. 그렇다면 과연 배열과 포인터는 완전히 같다고 할 수 있는가하면 그렇지는 않고 차이점도 많이 있다.

 

우선 포인터는 변수인데 비해 배열은 상수이다. pi는 고유의 메모리를 차지하고 있고 언제든지 다른 대상을 가리킬 수 있지만 ar은 선언할 때 그 위치가 이미 고정되므로 다른 대상을 가리킬 수 없다. ar로는 오로지 배열의 선두 번지를 읽을 수 있을 뿐이며 이 선두 번지를 기준으로 하여 배열 요소를 읽는다.

pi가 가리키는 배열의 크기는 동적으로 결정할 수 있지만 ar이 가리키는 배열의 크기는 선언할 때 정적으로 결정된다. 고정된 길이의 배열이 필요하면 int ar[n]; 선언문으로 배열을 생성하는 것이 편리하고 가변 길이의 배열이 필요하면 int *형의 포인터 변수를 선언한 후 malloc으로 할당해서 사용해야 한다. 포인터로 할당한 배열은 실행중에라도 realloc으로 크기를 재할당하여 변경할 수 있다.

배열은 그 자체가 크기 때문에 함수의 인수로 전달할 수 없지만 포인터는 대상체가 무엇이든간에 4바이트의 크기밖에 차지하지 않으므로 함수로 전달할 수 있다. 그래서 배열을 함수로 전달할 때는 반드시 포인터를 사용해야 한다.

배열로 요소를 읽는 것과 포인터로 대상체를 읽는 동작은 속도 차이가 있다. 배열의 첨자 연산은 매번 배열 선두에서부터 출발하지만 포인터는 대상체로 직접 이동해서 바로 읽으므로 액세스 속도가 빠르다. *pi는 pi가 가리키는 곳을 바로 읽지만 ar[n]은 *(ar+n)으로 일단 번지를 더한 후 읽어야 하므로 조금 느리다. 대단한 속도 차이는 아니지만 대규모의 반복적인 루프에서는 이 속도차도 결코 무시 못할 정도로 크다. 대략 포인터가 배열보다 두 배 정도 빠르다.

 

배열과 포인터의 관계를 좀 더 고찰해 보기 위해 pi를 한단계 더 확장해 보자. 다음 선언문에서 크기 5는 임의의 정수 상수로 줄 수 있다.

 

int *api[5];

 

이렇게 선언된 api는 정수형 포인터 변수 5개를 모아 놓은 포인터 배열이 된다. 배열의 각 요소인 api[0], api[1] 따위는 정수형 변수 또는 임의 크기로 동적 할당된 정수형 배열을 가리킬 수 있는데 이 배열이 메모리에 할당된 모습을 그려 보면 다음과 같다.

 api 배열을 위해 할당되는 메모리는 20바이트밖에 안되지만 각 요소는 int *형이므로 동적으로 할당된 번지를 api의 각 요소에 대입하면 api는 정수형 배열을 가리키는 포인터의 배열이 될 수 있다. 그렇다면 다음 선언문은 어떻게 해석할 수 있을까?

 

int **papi;

 

형태상 이중 포인터이다. papi의 요소인 papi[n] 은 int * 타입으로서 정수형 배열을 가리킬 수 있는 포인터이다. 이런 포인터 변수 여러 개를 모으면 포인터 배열이 되고 그런 배열을 가리키는 포인터를 선언하면 바로 papi가 된다. 그래서 papi는 포인터 배열 포인터가 되며 위 그림의 api같은 포인터 배열을 가리킬 수 있다. 한단계 더 확장해 보자.

 

int **apapi[3];

 

이 선언문은 포인터 배열 포인터 배열이며 papi같은 변수들의 집합을 다룬다. 말이 좀 꼬이더라도 쉽게 풀어 보자면 "정수형 변수의 집합인 배열을 가리킬 수 있는 포인터의 집합인 배열을 가리키는 포인터의 집합인 배열"이다. 말로 풀었더니 더 어려워지는 것 같은데 좀 더 쉽게 그림으로 그려 보자.

물론 apapi를 가리키는 포인터를 또 만들 수 있고 그런 포인터를 모아서 배열로 만드는 것도 가능하다. 현실적으로 이 정도까지 포인터와 배열을 중첩해서 쓸 일은 많지 않다. 그러나 이런 복잡한 수준의 선언문까지 해석할 수 있을 정도로 연습을 해 볼 필요는 있다. 가장 쉬운 선언문인 i부터 시작해서 pi, api, papi, apapi까지 단계적으로 확장해 보고 각각이 메모리에 어떻게 생성되는지 연습장에 그려 보자. 이 그림을 혼자서 완벽하게 그릴 수 있다면 포인터를 이해했다고 볼 수 있으며 남에게 설명할 수 있다면 포인터를 정복했다고 생각해도 좋다.