10-1-다.포인터 연산

포인터 연산이란 피연산자 중의 하나가 포인터인 연산이다. pi++과 같이 포인터형 변수에 대한 연산, pi1-pi2같이 포인터끼리의 연산이나 ar-pi, pi+3같이 포인터 변수나 포인터 상수가 피연산자 중에 하나라도 있으면 이런 연산을 포인터 연산이라고 한다. 포인터라는 타입이 정수나 실수같은 수치들과는 다른 독특한 타입이기 때문에 포인터 연산도 일반적인 산술 연산과는 다른 규칙이 적용된다.

이 규칙들을 잘 숙지하고 있어야 포인터를 자유 자재로 다룰 수 있다. 규칙이 좀 많기는 하지만 지극히 상식적인 내용들이기 때문에 이해하기 어렵지 않으며 한 번만 이해해 두면 실무에 적용하는데 큰 혼란은 없을 것이다. 중요한 내용이지만 생각보다 쉬우므로 가벼운 마음으로 읽어보면 된다.

 

 포인터끼리 더할 수 없다.

덧셈은 가장 기본적인 연산이지만 포인터끼리의 덧셈은 허용되지 않는다. 왜냐하면 번지값끼리 더한다는 것은 아무런 의미가 없기 때문이다. 변수 x, y를 포인터 변수 px, py가 각각 가리키고 있다고 하자. 설명의 편의상 x는 1000번지에 있고 y는 1500번지에 있다고 가정한다. 이때 포인터끼리 더하는 px+py 연산은 허용되지 않는다.

번지라는 타입은 부호없는 정수형이며 따라서 px와 py를 굳이 더하고자 한다면 2500으로 연산할 수도 있다. 그러나 이런 연산을 허용하지 않는 이유는 2500이라는 결과값이나 또는 2500번지에 들어있는 값이 x나 y 모두에게 어떠한 의미도 없기 때문이다.

조금만 생각해 보면 번지끼리 더한 값이 의미가 없다는 것을 직감적으로 이해할 수 있을 것이다. 만약 이해가 잘 안된다면 실생활에서 비슷한 예를 들어 보자. 내가 2000원을 가지고 있고 친구가 1000원을 가지고 있다면 둘이 가진 돈을 합쳐 3000원이라는 결과를 만들 수 있으며 이 3000원은 분명히 의미가 있는 값이다. 3000원으로 붕어빵 12개를 사 먹을 수도 있고 게임방에서 한시간동안 놀 수도 있다. 둘이 가진 돈을 더한 3000원이 의미가 있는 이유는 금액이라는 단위가 양으로 평가되는 성질이 있기 때문이다.

그러나 등급이나 나이같은 단위는 좀 다르다. 내가 25살이고 친구가 26살일 때 두 사람의 나이를 더한 51살은 도대체 어떤 의미가 있을까? 나이를 더한 51이라는 수치에는 어떠한 의미도 부여할 수가 없다. 둘의 나이를 더하면 51살이므로 둘이 함께 대통령 후보로 출마한다든가(대통령 피선거 자격은 50살 이상) 또는 옆집에 사는 49살짜리 건달에게 가서 우리가 나이가 더 많다고 건달을 혼내 준다든가 하는 일이 가능한가 말이다.

나이끼리 더한 값이 아무 의미가 없는 이유는 나이라는 단위가 양으로 평가되는 것이라기 보다는 구분 내지는 위치로 평가되는 성질이 더 강하기 때문이다. 그래서 나이값을 더할 수는 있지만 더해 봤자 아무짝에도 쓸모없는 값이 나오게 된다. 아마 여러분들도 실생활에서 나이끼리 덧셈을 해 본 경험이 없을 것이다. 이런 식으로 숫자로 표기하지만 양이 아닌 단위들이 많이 있는데 학생의 출석 번호나 인터넷 IP 번호 등도 그 한 예이다. 이런 값들은 다수의 대상들을 구분하는 것이 목적이다.

번지값도 마찬가지로 덧셈 자체는 가능하지만 그 결과값이 아무런 의미를 가지지 못한다. 그래서 포인터끼리 뎃셈 연산을 하면 컴파일러가 에러 메시지를 출력하고 컴파일을 거부한다. 포인터끼리의 덧셈 연산문은 99.99%이상 프로그래머의 실수일 가능성이 높으며 이 연산문을 그대로 컴파일하면 대개의 경우 프로그램이 오작동할 위험이 있다. 그래서 이런 위험을 방지하기 위해 포인터끼리의 덧셈은 금지되어 있다.

즉 포인터끼리의 덧셈을 금지하는 이유는 불가능해서가 아니라 대부분의 경우 단순한 실수일 확률이 높기 때문이다. 만약 어떤 이유로 두 포인터를 꼭 더하고야 말겠다면 굳이 못할 것도 없다. 포인터를 unsigned로 캐스팅해서 더한 후 다시 포인터 타입으로 캐스팅하면 가능하다. 컴파일러는 명시적인 캐스트 연산자에 대해서는 어떠한 태클도 걸지 않는다.

 포인터끼리 뺄 수는 있다.

포인터끼리 더한 값은 아무런 의미가 없지만 뺀 값은 두 요소간의 상대적인 거리라는 의미가 있다. 그래서 포인터끼리의 뺄셈은 원칙적으로 허용되며 실제로 많이 사용된다. 다음 예제를 실행해 보자.

 

: PointerMinus

#include <Turboc.h>

 

void main()

{

     char ar[]="Pointer";

     char *pi1, *pi2;

 

     pi1=&ar[0];

     pi2=&ar[5];

 

     printf("%c와 %c의 거리는 %d\n",*pi1,*pi2,pi2-pi1);

}

 

타입이 같은 임의의 두 포인터에 대해 뺄셈이 가능하다. 하지만 일반적으로 두 포인터가 같은 배열내의 다른 요소에 가리키고 있을 때만 실질적인 의미가 있다. 문자형의 ar배열은 "Pointer"라는 문자열을 저장하고 있으며 pi1은 첫 번째 요소인 'P'자 위치를 가키고 있고 pi2는 여섯 번째 요소인 'e' 문자를 가리키고 있다. 이 상태에서 pi2-pi1연산문은 두 배열 요소의 거리를 구한다.

실행해 보면 "P와 e의 거리는 5"라는 결과가 출력되는데 pi2-pi1 연산에 의해 문자 'P'와 'e'가 5만큼 떨어져 있다는 것을 알 수 있다. 만약 두 문자 사이에 몇개의 문자들이 있는지를 알고 싶다면 거리에서 1을 빼 pi2-pi1-1을 계산하면 되는데 o, i, n, t 4개의 문자가 가운데에 있다는 것을 알 수 있다. 두 요소 사이의 메모리 크기를 계산하고 싶다면 뺄셈한 결과에 sizeof(요소타입)를 곱한다.

내 나이가 25살이고 동생의 나이가 23살일 때 이 두 사람의 나이를 뺀 값은 두 사람의 나이차가 되며 분명히 의미가 있다. 동생이 두 살을 더 먹으면 내 나이와 같아진다라든가 내가 동생보다 두 살 더 많다는 표현이 가능하지 않은가? 나이값끼리 뺄셈이 가능한 것처럼 포인터끼리의 뺄셈도 가능하다.

포인터끼리 뺄셈을 한 연산 결과는 더 이상 포인터가 아니며 단순한 정수값이다. 두 사람의 나이차가 더 이상 나이가 아닌 단순한 수치가 되어 버리는 것처럼 두 포인터의 상대적인 거리도 번지가 아닌 정수값이다. 따라서 다음과 같은 대입 연산은 안된다.

 

ptr1=ptr2-ptr3;

 

ptr2-ptr3은 적법한 연산이지만 이 연산의 결과인 정수를 ptr1이라는 포인터 변수에 대입할 수는 없다. 그래서 앞의 예제에서도 pi2-pi1에 대응되는 printf의 서식은 정수형인 %d였다.

포인터끼리 뺄셈을 한 결과는 일단 정수형인데 정확한 타입은 컴파일러에 따라 다르며 표준은 포인터의 뺄셈 결과 타입을 stddef.h에 ptrdiff_t 타입으로 정의하도록 규정하고 있다. 비주얼 C++의 경우는 int로 typedef되어 있으며 Dev-C++은 long int로 정의하고 있다. 포인터는 원래 부호가 없는 타입이지만 뒤쪽 번지에서 앞쪽 번지를 뺄 수도 있으므로 포인터끼리 뺄셈한 결과는 부호가 있어야 한다. 주소 공간이 16비트인 시스템에서는 아마도 short가 될 것이다.

 포인터에 정수를 더하거나 뺄 수 있다.

포인터끼리 더할 수는 없지만 포인터와 정수를 더할 수는 있다. 정수 덧셈의 다른 표현인 ++, --도 당연히 가능하다. 내 나이가 25살일 때 앞으로 세 살 더 먹으면 장가를 가겠다는 표현이나 다섯 살을 더 먹으면 서른살이 된다는 등의 표현이 가능하지 않은가? 포인터 변수 ptr에 정수 i를 더한 ptr+i는 ptr이 가리키고 있는 번지에서부터 i번째 요소의 번지를 나타내는 의미있는 값이다.

ptr+1은 바로 다음 요소의 번지를 가리키며 ptr+2는 다음 다음 요소의 번지를 가리킨다. 포인터와 정수의 뺄셈도 가능한데 prt-1은 바로 이전 요소, ptr-2는 두칸 앞쪽의 요소를 가리킬 것이다. 물론 이 연산도 실질적인 의미가 있으려면 ptr이 배열내의 한 지점을 가리키고 가감한 결과도 같은 배열내에 속해야 한다. 앞에서 살펴 봤지만 포인터와 정수를 더할 때 실제 포인터값의 이동 거리는 sizeof(타입)만큼이다. ptr이 정수형 포인터일 때 ptr+1은 ptr의 번지보다 4바이트 뒤쪽을 가리킨다.

포인터에 정수를 더하거나 뺀 연산의 결과는 역시 포인터이다. ptr+i는 ptr번지에서 i요소만큼 뒤쪽을 가리키는 번지값이므로 이 연산 결과도 포인터가 되는 것이다. 포인터끼리 뺄셈한 결과가 정수인 것과는 구분해야 한다. 따라서 다음과 같은 연산은 적법하다.

 

ptr1=ptr2+2;

ptr1=ptr2--;

 

ptr2에 2를 더한 결과는 ptr2의 다음 다음 요소를 가리키는 포인터이며 따라서 이 결과를 ptr1에 대입 가능하다.

 포인터끼리 대입할 수 있다. 정수형 포인터 p1, p2가 있을 때 p1=p2 대입식으로 p2가 기억하고 있는 번지를 p1에 대입할 수 있다. 너무 너무 당연한 얘기라 별도의 설명이 필요없을 것이다. 다만 대입식의 좌변과 우변의 포인터 타입이 일치해야 한다는 것만 주의하자. 만약 두 포인터의 타입이 틀릴 경우는 캐스트 연산자로 타입을 강제로 맞추어야 한다.

 

int *pi,i=1234;

unsigned *pu;

pi=&i;

pu=(unsigned *)pi;

 

pi는 부호있는 정수형 포인터이고 pu는 부호없는 정수형 포인터인데 pu=pi로 바로 대입하면 타입이 맞지 않으므로 에러(터보 C는 경고)로 처리된다. 꼭 대입하려면 캐스트 연산자를 사용할 수 있되 대입 후의 결과에 대해서는 개발자가 책임져야 한다.

 포인터와 실수와의 연산은 허용되지 않는다.

번지라는 값은 정수의 범위에서만 의미가 있기 때문에 실수와는 어떠한 연산도 할 수 없다. pc+0.5라는 연산을 허용한다면 이 번지는 도대체 어디를 가리켜야 할지 애매할 것이다. 바이트 중간의 비트를 가리킬 수는 없는데 왜냐하면 비트는 기억의 최소단위일뿐이며 번지를 가지는 최소 단위는 바이트이기 때문이다. 따로 논리적인 이유를 대지 않더라도 포인터와 실수와의 연산은 전혀 어울리지 않으며 필요하지도 않다는 것을 쉽게 이해할 수 있을 것이다. 포인터와 실수를 연산하면 컴파일러는 "피연산자는 정수만 써 주세요"라는 에러 메시지를 출력한다.

 포인터에 곱셈이나 나눗셈을 할 수 없다.

포인터에 곱셈, 나눗셈을 하는 것이 불가능한 일은 아니지만 연산의 필요성이 전혀 없다고 해야 옳을 것이다. 번지값을 곱해서 도대체 어디다 쓸 것인가를 곰곰히 생각해 보면 역시 전혀 필요가 없다는 것을 알 수 있다. 실생활에서 나이에 어떤 값을 곱할 일이 없는 것처럼 말이다. 음수 부호 연산자(-), 나머지 연산자(%), 비트 연산자, 쉬프트 연산자 등도 모두 포인터와는 함께 쓸 수 없는 연산자들이다.

 포인터끼리 비교는 가능하다.

두 포인터가 같은 번지를 가리키고 있는지를 조사하기 위해 ==, != 상등 비교 연산자를 사용할 수 있으며 산술 연산과 동일한 의미를 가진다. 물론 이때 양쪽의 데이터 타입은 일치해야 한다. 포인터끼리 값을 비교하는 일은 그리 흔한 일은 아니지만 포인터값의 유효성을 점검하기 위해 NULL값과 비교하는 연산은 자주 이용된다. 포인터형을 리턴하는 함수들은 에러가 발생했다는 표시로 NULL을 리턴하므로 이 함수들의 실행 결과를 점검하기 위해 포인터와 NULL을 비교한다.

 

if (ptr == NULL)

if (ptr != NULL)

 

이런 비교 연산문은 거의 대부분 "에러가 발생했으면~"이라는 조건식이다. 상등 비교 연산자뿐만 아니라 <, >, <=, >= 등의 크기를 비교하는 연산자도 사용할 수 있다. 이때 양쪽은 데이터 타입이 일치해야 하는 것은 물론이고 의미있는 비교가 되려면 같은 배열내의 포인터여야 한다. 다음 조건문은 "ptr1이 ptr2보다 더 뒤쪽의 요소를 가리키고 있으면" 이라는 뜻이다.

 

if (ptr1 > ptr2)

 

이상으로 포인터 연산시의 주의 사항에 대해 알아보았는데 양이 좀 많기는 하지만 그다지 어렵지는 않았을 것이다. 포인터에 실수를 쓸 수 없다거나 곱하기나 나누기가 불필요하다는 것은 상식적인 내용이므로 이해만 하면 될 것이고 다음 세가지 주의 사항만 따로 암기해 두도록 하자. 본격적으로 어려워지기 전에 이 정도는 반드시 암기하는 것이 차후의 혼돈을 조금이라도 줄이는 방책이 될 수 있다.

 

포인터끼리 더할 수 없다.

포인터끼리 뺄 수 있으며 연산 결과는 정수이다.

포인터와 정수의 가감 연산은 가능하며 연산 결과는 포인터이다.

 

포인터 연산의 종합 실습을 위해 두 포인터의 중점을 구하는 예제를 작성해 보자. 크기 5의 정수형 배열 ar[5]가 있고 p1이 ar[0]를 가리키고 p2가 ar[4]를 가리키고 있을 때 두 포인터의 중간 지점의 값을 읽고 싶다고 하자. 그림에서 보다시피 ar[2]의 내용인 3의 값을 구하고 싶은 것이다.

일반적인 산술 연산에서 a와 b의 중점은 (a+b)/2로 쉽게 구할 수 있다. 그러나 포인터끼리의 덧셈이 허용되지 않기 때문에 이런 간단한 공식으로는 중간 지점을 구할 수 없으며 다음 예제와 같이 계산해야 한다.

 

: MidPointer

#include <Turboc.h>

 

void main()

{

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

     int *p1,*p2,*p3;

 

     p1=&ar[0];

     p2=&ar[4];

     p3=p1+(p2-p1)/2;

 

     printf("중간의 요소 = %d",*p3);

}

 

덧셈이 허용되지 않으므로 p2-p1으로 두 지점의 거리를 구하고 시작번지(base)인 p1에 거리의 절반을 더하여 중간 지점의 번지를 구했으며 그 결과를 p3에 대입했다. 그리고 *p3를 출력하면 원하는 중간 지점의 값인 3이 출력된다. 이 연산이 어떻게 동작하며 왜 적법한지 잘 분석해 보자.

포인터끼리는 뺄셈이 가능하므로 p2-p1은 적법한 연산문이며 뺄셈 결과는 정수가 되므로 이 값을 2로 나눌 수 있다. (p2-p1)/2라는 수식은 두 포인터간의 절반 거리를 나타내는 정수 상수를 만들어낸다. 포인터에 정수를 더할 수 있으므로 p1에 절반 거리를 더할 수 있고 그 결과는 포인터이므로 p3가 이 포인터값을 대입받을 수 있다.

이 연산문은 아주 간단하지만 포인터 연산에 대한 모든 것을 한 줄에 다 품고 있는 아주 핵심적인 코드이다. 차후에 포인터 연산 규칙이 잘 생각나지 않을 때 이 연산문을 떠올려 보면 많은 도움이 될 것이다. 이 연산문을 통째로 외우고 싶다면 적극 권장하고 싶으며 이 예제를 통째로 외우겠다면 굳이 말리지 않겠다.