11-1-나.[ ] 연산자

배열의 한 요소를 참조할 때는 [ ] 연산자를 사용하며 [ ] 괄호안에 읽고자 하는 배열 요소의 첨자를 적는다. 예를 들어 ar[3]은 ar 배열의 3번째 요소를 읽는다. [ ] 괄호는 언뜻 보기에 구두점같아 보이지만 실제로는 첨자 연산을 하는 포인터 연산자이다. 이 연산자의 동작은 정확하게 다음과 같이 정의되어 있다.

 

ptr 임의의 배열을 가리키는 포인터이고 n 정수일

ptr[n] = *(ptr+n)

 

정의의 전제 조건대로 둘 중 하나는 반드시 포인터여야 하고 하나는 정수여야 한다. 둘 다 포인터라면 덧셈 연산을 할 수 없으며 둘 다 정수라면 *연산자를 적용할 수 없을 것이다. 또한 n이 실수일 경우 포인터와 실수를 더할 수 없으므로 이 또한 적법하지 않다. 이 정의를 확인해 보기 위해 다음 예제를 실행해 보자.

 

: ArrayIndex

#include <Turboc.h>

 

void main(void)

{

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

 

     printf("ar[2]=%d\n",ar[2]);

     printf("ar[2]=%d\n",*(ar+2));

     printf("ar[2]=%d\n",2[ar]);

}

 

크기 5의 정수형 배열 ar을 선언 및 초기화하고 이 배열의 2번째 요소를 다양한 방법으로 읽어 보았다. 실행 결과 "ar[2]=3"이라는 똑같은 문자열이 세 번 출력되는데 세 방법이 모두 같은 배열 요소를 읽어낸다. ar[2] 연산식은 ar 배열의 2번째 요소를 의미하므로 당연히 3이 읽혀진다. 더 이상 이유를 설명할 필요가 없을 정도로 당연한 결과이다.

*(ar+2)도 마찬가지로 ar 배열의 2번째 요소를 읽는다. 앞 장에서 실습했다시피 배열명 그 자체는 배열의 시작 번지를 가리키는 포인터 상수이므로 ar배열명은 ar 배열의 시작 번지를 가리킬 것이다. 이 번지에서 +2하여 두 칸 다음으로 이동하면 8바이트 뒤쪽 번지가 된다. ar이 정수형 배열(따라서 정수형 포인터 상수)이므로 +2는 2*sizeof(int) 바이트 더 뒤쪽이 되며 포인터와 정수를 더했으므로 결과는 역시 포인터이다. 마지막으로 *연산자는 이 번지 이후 4바이트의 정수를 읽으므로 결과는 3이 된다.

*(ar+2)가 어째서 ar[2]와 같은지 이유를 따질 필요가 없다. 이것은 일종의 약속이며 [ ] 연산자는 지정한 첨자의 배열 요소를 읽는 포인터 연산을 하도록 정의되어 있는 것이다. 포인터가 가리키는 배열의 n번째 요소를 읽으려면 배열의 시작 번지에서 n만큼 더한 후 *연산자로 그 번지의 값을 읽으면 된다. 이 연산식이 바로 *(ptr+n)이며 이 식은 포인터 연산 규칙과 *연산자에 의해 배열 요소를 아주 잘 읽어낸다.

그래서 *(ptr+n)식만 이해할 수 있으면 원하는 배열 요소를 자유롭게 액세스할 수 있다. 그런데 이 식은 잘 동작하기는 하지만 직관적이지 못하고 왠지 복잡해 보인다. 게다가 배열 요소를 읽어야 할 경우는 아주 흔하기 때문에 좀 더 읽기 쉬운 형태의 [ ] 연산자를 정의해 놓은 것이다. 아무래도 *(ptr+n)보다는 ptr[n]이 더 읽기 쉽고 보기에도 좋다. 배열 요소를 읽을 때 사용하는 [ ] 연산자는 내부적으로 포인터 연산을 수행하는 포인터 연산자로 해석되며 *(ptr+n)과 완전하게 동일한 식이다.

[ ] 연산자가 배열 요소 참조를 위해 특별히 따로 정의되어 있는 것이 아니라 알고 보면 포인터 연산의 다른 표기법일 뿐이다. 위 예제의 세 번째 출력문은 이를 분명히 확인시켜 준다. 2[ar]이라는 표현식이 어색해 보이고 틀린 것 같지만 에러도 발생하지 않고 실행도 정상적이다. 왜 그런지 보자. 정의에 의해 2[ar]은 *(2+ar)이 되고 덧셈은 교환법칙이 성립하므로 이 식은 *(ar+2)와 같다. 결국 2[ar]=ar[2]와 완전히 같은 식이므로 컴파일러가 이 식을 해석하는데 아무 문제가 없는 것이다.

사실 ptr[n] 표현식은 컴파일러에 의해 *(ptr+n)으로 바뀐 후 컴파일되며 생성되는 기계어 코드도 완전히 동일하다. 연산 순위표를 보면 [ ] 연산자가 *보다 우선 순위가 높은 것으로 되어 있는데 이는 두 연산자가 달라서 그런 것이 아니라 [ ] 연산자의 정의에 괄호가 포함되어 있기 때문이다. 괄호는 어떠한 연산자보다도 우선 순위가 높으며 따라서 [ ] 연산자가 *보다 더 높은 우선 순위를 가지는 것은 지극히 자연스럽다.

[ ] 연산자가 1차 배열에 대해 동작하는 것은 일종의 약속이므로 외우기만 하면 된다. 그렇다면 2차 배열에서 [ ] 연산자가 어떻게 동작하는지 연구해 보자. 이 과정을 보면 확실히 C의 다차원 배열은 배열의 배열이라는 것을 확인할 수 있다. int ar[3][4] 배열에서 ar[2][1] 연산식을 분석해 보자. ar[2][1]은 정의에 의해 *(*(ar+2)+1)이 되며 컴파일러는 이 포인터 연산식을 실행할 것이다. 연산 순위에 따라 괄호의 제일 안쪽부터 연산된다.

 

 ar+2

전체 배열명 ar은 배열의 선두 번지(&ar[0][0])를 가리키는 포인터 상수이다. 이 번지에 2를 더하면 ar 배열의 요소 중 2번째 요소를 가리키게 될 것이다. ar은 ar[0], ar[1], ar[2] 세 개의 부분 배열을 가지는 1차원 배열이므로 ar+2의 연산 결과는 부분 배열 ar[2]의 선두 번지값이 된다. 포인터에 정수를 더한 ar+2의 결과는 부분 배열을 가리키는 포인터이므로 이 단계에서 sizeof(ar+2)를 계산해 보면 4가 될 것이다.

포인터에 대한 덧셈 연산은 포인터가 가리키는 대상체의 타입만큼 번지를 더한다. 만약 ar을 2차 정수형 배열로 본다면 ar의 타입은 정수형일 것이고 +2는 대상체인 정수형의 크기 4를 곱해 8만큼 이동할 것이다. 그러나 실제로는 32바이트 증가하는데 이를 보면 ar의 요소가 정수형이 아니라 크기 4의 정수형 부분 배열(크기는 16바이트)이라는 것을 알 수 있다. 컴파일러가 ar을 2차 정수 배열로 인식한다기 보다는 정수형 부분 배열의 1차 배열로 인식한다는 것을 확인할 수 있다.

 *(ar+2)

이 연산식은 정의에 의해 ar 배열의 2번째 부분 배열 ar[2]이다. *연산자는 피연산자의 크기만큼을 읽어내는데 ar+2가 부분 배열이므로 읽혀지는 값은 정수형이 아니라 크기 4의 정수 배열형이 될 것이다. 이 단계에서 sizeof(*(ar+2))를 계산해 보면 16이 되는데 배열이 sizeof의 피연산자일 때는 배열 그 자체로 평가되기 때문이다. *(ar+2)는 ar의 부분 배열 ar[2]와 완전히 같고 이 값은 ar[2] 부분 배열의 선두 번지(&ar[2][0])로 평가된다.

 *(ar+2)+1

부분 배열 ar[2]에서 1번째 배열 요소를 가리키는 포인터가 된다. *(ar+2)가 부분 배열명이므로 포인터 상수이고 포인터 상수에 정수를 더했으므로 결과는 포인터가 되는 것이다. 정의에 의해 *(ar+2)+1은 ar[2]+1과 같고 이 값은 &ar[2][1]과도 동일하다. &ar[2][1]을 정의대로 역으로 다시 풀어 쓰면 &*(*(ar+2)+1)이 되는데 &*는 상쇄되어 사라지고 결국 ar[2]+1과 같아진다. sizeof(*(ar+2)+1)은 포인터이므로 4이다.

 *(*(ar+2)+1)

마지막으로 * 연산자를 사용하여 3단계에서 구한 포인터에 들어 있는 값을 읽었다. ar[2]의 배열 요소는 더 이상 부분 배열이 아닌 단순한 정수형이므로 이 번지에 있는 정수값 4바이트가 읽혀질 것이다. 결국 최종 연산 결과는 ar의 2번째 부분 배열 ar[2]의 1번째 배열 요소인 ar[2][1]이 된다.

보다시피 ar[2][1]은 *(*(ar+2)+1)과 완전히 동일하며 컴파일러는 ar[2][1]같은 연산문을 만날 때 동일한 포인터 연산식으로 바꾼 후 배열 요소를 읽는다. 부분 배열의 선두 번지를 계속 구해 나가다가 최하위의 배열 요소를 찾아내서 그 값을 읽는 단계적인 포인터 연산을 하는 셈이다. 단계가 복잡해서 굉장히 느릴 것 같지만 컴파일러의 입장에서 포인터 연산은 단순한 덧셈과 읽기 동작이기 때문에 전혀 느리지 않다.

베이직같은 고급 언어들의 다차원 배열 참조 연산은 C의 그것과는 상당히 다르다. T arr[A,B,C]라는 3차원 배열에 arr[a,b,c]를 읽으면 *(arr+(aBC+bC+c)*sizeof(T))식으로 한 번에 최종 요소값을 읽어낸다. C와는 달리 첨자 연산이 따로 정의되어 있으므로 최종 요소만 읽을 수 있으며 부분 배열을 개별적으로 액세스하는 것은 허가되지 않는다. 다음 예제는 이 연산문이 실제로 어떻게 동작하는지 확인해 보기 위해 작성한 것이다.

 

: ArrayIndex2

#include <Turboc.h>

 

void main(void)

{

     int ar[3][4]={{1,2,3,4},{5,6,7,8},{9,10,11,12}};

 

     printf("ar[2][1]=%d\n",ar[2][1]);

     printf("ar[2][1]=%d\n",*(*(ar+2)+1));

 

     printf("sizeof(ar+2)=%d\n",sizeof(ar+2));

     printf("sizeof(*(ar+2))=%d\n",sizeof(*(ar+2)));

     printf("sizeof(*(ar+2)+1)=%d\n",sizeof(*(ar+2)+1));

}

 

[ ] 연산자와 포인터 연산자로 ar[2][1]을 각각 읽어 보았는데 같은 연산문의 다른 표기법일 뿐이므로 결과는 동일하다.

 

ar[2][1]=10

ar[2][1]=10

sizeof(ar+2)=4

sizeof(*(ar+2))=16

sizeof(*(ar+2)+1)=4

 

중간 과정을 살펴보기 위해 중간식의 크기를 계산해 보았는데 ar+2는 부분 배열의 시작 번지를 가리키는 포인터이되 *(ar+2)는 부분 배열 그 자체라는 것을 확인할 수 있다. ar[2][1] 대신 2[ar][1], 1[2[ar]], 1[ar[2]]라고 써도 결과는 동일하다. 물론 이렇게 쓸 필요가 전혀 없지만 연습삼아 이 식들이 왜 동일한지 점검해 보아라.

[ ] 연산자의 정의와 동작에 대해 비교적 상세하게 분석해 보았는데 이 연산자의 동작을 설명하는데는 포인터에 대한 세 가지 필수적인 정의가 동원된다. ① 배열명은 포인터 상수이다. ② 포인터와 정수끼리 덧셈하면 sizeof(T)만큼 이동하고 그 결과는 포인터이다. ③ *연산자는 포인터가 가리키는 번지의 내용을 읽는다. 이 정도는 아마 무난히 이해하고 있을 것이다.

사실 이런 복잡한 내부 처리 과정을 굳이 모르더라도 배열을 사용하는데 불편함은 없다. 정의란 외워서 사용하는 것이며 한 번만 이해하면 다음부터는 잊어 버려도 상관없다. 피타고라스의 정리나 근의 공식은 이해한 후 사용하는 대상이지 증명 자체를 외울 필요가 없는 것과 같다. 여기서 반드시 이해해야 할 요점은 [ ] 연산자가 결국은 포인터 연산을 한다는 것이다.