10-1-나.포인터의 타입

포인터가 가리키는 번지에 들어있는 값, 즉 포인터가 가리키는 실체를 대상체(object)라고 한다. 예를 들어 정수형 포인터의 대상체는 정수형 변수이며 실수형 포인터의 대상체는 실수형 변수이다. *연산자로 포인터가 가리키는 곳을 읽으면 포인터의 대상체 값이 읽혀질 것이다.

포인터 변수를 선언할 때는 가리키고자 하는 대상체의 타입을 반드시 명시해야 한다. 대상체의 타입을 포인터의 타입이라고 한다. 그래서 포인터형 변수의 타입은 대상체의 타입을 따라 ~에 대한 포인터형(Pointer to ~)이라고 표현한다. 예를 들어 정수형 포인터 pi를 다음과 같이 선언했다고 하자.

 

int *pi;

 

pi 변수는 물론 포인터형이지만 좀 더 정확한 타입은 "정수에 대한 포인터형"이라고 해야 한다. int *라는 타입이 정수형 변수를 가리키는 포인터형이라는 뜻이다. 이 선언문의 의미를 좀 더 따져보자. 포인터가 저장하는 번지값이라는 것은 4바이트 크기로 고정되어 있고 이 변수에 저장될 값은 항상 부호없는 정수형(unsigned int)이다. 32비트 환경에서 주소값은 항상 32비트이며 번지는 0을 포함한 양수값이다. 포인터 변수가 정수를 가리키든 실수를 가리키든 또는 구조체나 배열같은 큰 데이터를 가리키든 대상체의 타입과는 상관없이 포인터는 항상 4바이트의 부호없는 정수값인 것이다.

포인터 변수는 크기와 형태가 이미 고정되어 있는데 왜 정수를 가리키는 포인터, 실수를 가리키는 포인터 식으로 대상체의 데이터형을 꼭 밝혀야 할까? 포인터라는 고유의 타입을 나타내는 키워드를 새로 정의하고 Pointer p; 식으로 선언할 수도 있는데 말이다. 물론 이유가 있다. 포인터가 타입을 가져야 하는 이유는 다음 두 가지이다.

첫 번째 이유는 *연산자로 포인터의 대상체를 읽거나 쓸 때 대상체의 바이트 수와 비트 해석 방법을 알아야 하기 때문이다. 다음 예제를 실행해 보자.

 

: PointerType

#include <Turboc.h>

 

void main()

{

     int i=1234;

     int *pi;

     double d=3.14;

     double *pd;

 

     pi=&i;

     pd=&d;

     printf("정수 = %d\n",*pi);

     printf("실수 = %f\n",*pd);

 

     pi=(int *)&d;

     printf("pi로 읽은 d번지의 값 = %d\n",*pi);

}

 

실행 결과는 다음과 같다.

 

정수 = 1234

실수 = 3.140000

pi로 읽은 d번지의 값 = 1374389535

 

정수형 포인터 pi는 정수형 변수 i의 번지를 가리키며 실수형 포인터 pd는 실수형 변수 d의 번지를 가리키고 있다. 이 상태에서 *pi, *pd로 이 포인터들이 가리키는 곳의 대상체를 읽어 화면으로 출력했다. 정수형 포인터로 대상체를 읽으면 정수가 제대로 읽혀지고 실수형 포인터로 실수를 읽어도 제대로 읽혀진다.

*연산자는 포인터가 가리키는 곳의 대상체를 읽는 연산자이다. 이 연산자가 제대로 값을 읽기 위해서는 대상체의 타입을 정확하게 알고 있어야 한다. pi가 정수형 변수를 가리키는 포인터 변수이므로 *pi연산문은 pi가 가리키는 번지에서부터 4바이트를 읽어낸다. 그리고 이 대상체가 부호있는 정수형이라는 것을 알고 있으므로 제일 앞쪽 비트(MSB)를 부호 비트로 해석하고 나머지 비트는 절대값으로 평가할 것이다. 이에 비해 *pd 연산문은 pd 번지로부터 8바이트를 읽은 후 이 값을 부호, 가수, 지수로 분리한 후 정확한 실수값을 얻게 된다.

pi나 pd나 똑같이 메모리의 한 지점을 가리키는 포인터형 변수이지만 선언할 때 대상체의 타입을 명시했기 때문에 *연산자가 이 포인터들로부터 읽는 값이 달라질 수 있다. 만약 pi와 pd가 어떤 대상체를 가리키고 있는지 모른다면 *연산자는 이 포인터들이 가리키는 번지에 들어있는 값의 길이와 비트 해석 방법을 알지 못할 것이다. 메모리의 위치만 가지고는 정보가 부족하기 때문에 대상체를 제대로 읽을 수 없다.

예제의 끝에서는 정수형 변수 pi에 실수형 변수 d의 번지를 대입하여 *pi로 이 값을 읽어 보았다. 포인터의 타입과 대상체의 타입이 맞지 않기 때문에 &d, 즉 d의 번지를 pi에 대입하려면 반드시 이 번지값을 (int *)로 캐스팅해야 한다. pi=&d; 연산문으로 바로 대입하면 타입이 맞지 않아서 대입할 수 없다는 에러로 처리된다. 참고로 C++에서 pi=&d대입문은 에러로 처리되지만 C++보다 타입 체크가 덜 엄격한 C 컴파일러는 이를 경고로 처리한다.

pi가 번지값을 가리키는 포인터이고 &pd도 번지값이므로 캐스팅만 하면 강제로 대입할 수는 있다. 이 상태에서 *pi로 대상체의 값을 읽어 보면 엉뚱한 값이 출력된다. pi가 가리키는 메모리에는 8바이트 길이의 3.14라는 실수값이 들어 있지만 *연산자는 pi가 정수형 포인터이므로 이 번지에서 4바이트만 취해 정수값을 읽기 때문이다. 읽어야 할 값의 길이도 맞지 않지만 정수와 실수의 비트 패턴이 다르므로 3.14도 아니고 3도 아닌 완전히 엉뚱한 값이 읽혀진다.

변수가 저장되는 장소인 메모리에는 정수형, 실수형 따위의 별스러운 표식이 붙어 있는 것이 아니다. 모든 메모리는 8비트로 구성되어 있고 여덟자리의 이진수를 기억할 수 있다는 점에서 동질적이다. 그래서 메모리 위치를 가리키는 포인터는 자신이 가리키고 있는 번지에 저장된 값이 어떤 종류인지를 기억하고 있어야 하며 이런 이유로 포인터를 선언할 때 대상체의 타입을 밝혀야 하는 것이다. 3장에서 설명한 변수가 타입을 가져야 하는 두 가지 이유와 일맥 상통한다.

포인터가 대상체의 타입을 요구하는 두 번째 이유는 인접한 다른 대상체로 이동할 때 이동 거리를 알기 위해서이다. 이동 거리란 곧 대상체의 크기에 대한 정보를 의미한다. 번지를 가리키는 포인터도 일종의 변수이므로 실행중에 다른 번지를 가리키도록 변경할 수 있다. 이때는 보통 증감 연산자를 사용하는데 현재 위치에서 앞뒤로 이동함으로써 인접한 대상체로 이동한다. 다음 예제를 보자.

 

: PointerType2

#include <Turboc.h>

 

void main()

{

     int ar[]={1,2,3,4,5};

     int *pi;

 

     pi=ar;

     printf("첫 번째 요소 = %d\n",*pi);

     pi++;

     printf("두 번째 요소 = %d\n",*pi);

}

 

크기 5의 ar 배열을 정의했으며 pi=ar 대입문으로 pi가 ar 배열의 선두 번지를 가리키도록 했다. 배열명 자체는 배열의 시작번지를 가리키는 포인터 상수이므로 pi가 이 포인터 상수를 대입받을 수 있다. pi=&ar이 아니며 이렇게 대입해봐야 대입되지도 않음을 주의하도록 하자. pi가 ar 배열의 시작 번지, 그러니까 ar[0]의 번지를 가리키고 있는 상황이다. 이 상태에서 *pi를 읽으면 pi 위치에서 4바이트를 읽을 것이며 이 값을 출력하면 첫 번째 요소 1이 출력된다.

그리고 pi++ 연산문으로 pi값을 증가시켜 ar 배열의 다음 요소로 이동했으며 이 상태에서 *pi를 읽으면 ar[1]의 값이 출력된다. 이 예제의 전체 실행 순서를 그림으로 그려 보면 다음과 같다.

ar[0]와 ar[1]의 값이 각각 출력된다. 너무나 당연한 결과로 보이겠지만 과연 당연한 것인지 특별한 것인지 좀 더 생각해 보자. ar 배열이 할당되는 번지는 실행할 때마다 달라지겠지만 설명의 편의상 1000번지에 할당되었다고 하자. pi가 최초로 대입받은 ar 배열의 시작번지는 1000번지가 될 것이며 1000번지에서 1003번지까지 4바이트를 읽어 ar[0]를 출력했다.

그리고 다음 번지로 이동하기 위해 pi++ 연산을 했는데 이 연산에 의해 pi는 1001번지로 이동해야 한다. 1000에다 1을 더하면 1001이 된다는 것은 초등학생도 다 아는 산수다. 그러나 1001번지는 배열에 속해 있기는 하지만 아무런 의미가 없는 값이다. ar[0]도 아니고 ar[1]도 아니고 두 요소에 걸쳐있는 애매한 번지인 것이다.

이 번지의 값을 읽어서는 ar[1]의 값을 구할 수 없음은 물론이고 이것도 저것도 아닌 이상한 값이 읽혀질 것이다. 그래서 C는 포인터에 대한 증감 연산을 산술 연산과는 달리 아주 특수하게 수행한다.

 

T 포인터 변수 px 정수 i 더하면

px=px+(i*sizeof(T)) 된다.

 

pi의 경우 정수형 포인터 변수이므로 pi++은 sizeof(int) 만큼인 4바이트 증가하게 될 것이다. pi가 1000인 상태에서 pi++연산문은 pi를 1004로 만들며 이 상태에서 *pi를 읽으면 ar[1]의 값을 구할 수 있다. 이렇게 되어야 배열을 가리키는 포인터를 증감시켜서 배열의 다른 요소로 자유롭게 이동할 수 있을 것이다.

컴파일러가 포인터 연산에 대해 대상체의 크기만큼 앞뒤로 이동시킬 수 있으려면 이 포인터가 가리키는 대상체의 타입이 무엇인가를 알아야 한다. 그래서 포인터 변수를 선언할 때 가리킬 대상체의 타입을 명시하는 것이다. 포인터형 변수의 타입은 포인터가 가리키는 대상체에 대한 타입을 명시하여 "정수형 변수에 대한 포인터" 식으로 표현해야 하나 간단히 줄여서 "정수형 포인터"라고 칭한다.