본문 바로가기
IT/Programming

C 포인터, 확실히 알자(2) - 배열과 포인터

by Jany 2009. 3. 31.
반응형

시작

포인터를 얘기할 때, 꼭 배열이란 것을 다시 언급하게 됩니다.
사실 C에서 포인터(Pointer)와 배열(Array)은 꽤 닮은 점이 있습니다.
결론부터 말하면, 배열이 내부적으로 포인터로 구현되어 있기 때문입니다.

 

1. 배열(Array)

배열이란 동일한 타입을 가지는 일련의 변수군을 정의한 겁니다. 가령 int A[5]; 이라고 선언하면 정수(int)형 변수 5개를 만들고 그것을 A 라는 하나의 이름으로 통합해서 관리하게 됩니다.

그러면 메모리상에서 배열은 어떤 모습일까요?
일단 배열의 각 원소가 할당받는 어드레스를 살펴봅니다.

────────────────────────────────────────

   #include <stdio.h>

   void main()
   {
       int A[5];

       printf("%d %d %d %d %d", &A[0], &A[1], &A[2], &A[3], &A[4]);
   }

 

   [결과]









────────────────────────────────────────

int a, b, c, d; 이런식으로 선언할 때와는 다르게,
어드레스가 낮은쪽에서 높은쪽으로 순차적으로 부여되고 있습니다.
어쨌거나 중요한건 배열은 메모리상에서 연속된 공간을 할당받는 다는 겁니다.
 
지난 시간에 변수이름은 메모리 어드레스를 추상화 시킨 것이라고 했습니다.
그러면 배열 A는 과연 어떤 어드레스를 가지고 있을까요?
 
고민할거 없이 다음 예제를 실행시켜 봅니다. 

────────────────────────────────────────

   #include <stdio.h>

   void main()
   {
       int A[5];

       printf("%d %d %d %d %d", &A[0], &A[1], &A[2], &A[3], &A[4]);
       printf("%d", &A);
   }
 

   [결과]










────────────────────────────────────────

배열 A의 어드레스는 A[0]의 어드레스라는 걸 확인할 수 있습니다.
잠깐, 그럼 배열 A 자체는 무슨 값을 가지고 있는지 궁금하지 않나요?

────────────────────────────────────────

   #include <stdio.h>

   void main()
   {
       int A[5];

       printf("%d", &A);
       printf("%d", A);
   }


   [결과]









────────────────────────────────────────

배열명 A 자체가 가진 값은 바로 배열 자신의 어드레스로군요.

잠까안... 어드레스를 값으로 가지는 변수라...

그건 즉 포인터 아닙니까?
그렇다는 얘기는 배열 = 포인터라고 봐도 무방하지 않을까요?


2. 배열을 포인터로 접근하기

배열이 포인터와 같은 거라면, 배열의 각 원소에 접근할 때 A[0], A[1] 이런 방법 말고도, 포인터를 이용해서도 접근할 수 있을 겁니다. 정말인지 한번 해봅시다.

────────────────────────────────────────

   #include <stdio.h>

   void main()
   {
       int A[5];

       *A = 10;
       printf("%d", A[0]);
   }

   [결과]

   10

────────────────────────────────────────

"*A = 10" 이라는 문장은 곧 "A[0] = 10"과 같은 의미가 됩니다.
A가 가지고 있는 어드레스가 바로 A[0]의 어드레스이기 때문이죠.

그럼 포인터를 이용해서 A[1]에 값을 넣어보려면 어떻게 해야 할까요?
이럴때 바로 포인터 연산이 필요해집니다.

여기서 말하는 포인터 연산이란, 포인터 변수가 가지고 있는 어드레스 값을 조작하는 것을 의미합니다. 그런데 불행인지 다행인지, C에서는 포인터에 어드레스 상수를 직접 대입할 수 없습니다.

그러니까...

   int *p;

   p = 1245040;

이렇게 어드레스 숫자를 직접 포인터 변수에 대입시키는 것은 컴파일러가 절대 거부합니다. 그래서 & 연산자를 이용해서 변수의 어드레스값을 실행시(run-time)에 얻어와야 합니다.

하지만, 한번 포인터 변수에 어드레스가 대입되면, 그다음에는 +, - 연산을 통해서 얼마든지 어드레스 값을 변경시킬 수 있습니다.

이것도 한번 직접 해봅시다.

────────────────────────────────────────

  #include <stdio.h>

   void main()
   {
       int v;
       int *p;

       p = &v;

       printf("%d", p);

       p++;

       printf("%d", p);
   }

 

   [결과]










────────────────────────────────────────

첫번째에 포인터 p의 값을 출력해 보면 변수 v 의 어드레스 값이 그대로 나옵니다. 그런데 p++ 연산을 시킨 후에 p 값을 출력해보면...

엥? 4가 늘어났네??

p++ 이란건 p = p + 1 과 같은 의미이니까, 1245052 에서 1이 늘어난 1245053이 되어야 하는게 아닐까요?

잠깐, 여기서 타입(Type)이란 걸 생각해야 합니다.
int 타입의 변수는 크기가 얼마지요?

   printf("%d", sizeof(int));

   [결과]

   4

32bit CPU에서는 int 타입은 기본적으로 4byte (=32bit)를 차지합니다.

포인터도 결국은 메모리에 있는 데이터를 읽고 쓰기 위해 사용되어집니다. 그런데 int형 자료를 읽어와야 하는데, 아래처럼 어드레스가 어긋나 버리면 예상치 못한 이상한 데이터를 가져와 버리게 됩니다.

때문에 포인터의 어드레스를 +1 시키게 되면, 숫자 '1'을 더하는게 아니라 '1 x 타입크기' 만큼을 더하는 겁니다.

( +n => + n * sizeof(type) )

즉, 포인터에서 +1 이란 의미는 "다음 데이터가 있는 위치(어드레스)"라는 뜻입니다. 반대로 -1을 하게되면 "이전 데이터가 있는 위치"라는 것이 되겠지요.

자, 그럼 이제 위에서 하려고 했던 배열의 각 원소를 포인터를 이용해서 접근해 보겠습니다.

배열 A[1]에 접근하려고 하면 A의 어드레스를 +1 시키면 되겠죠.

   A++;

혹은

   A += 1;

또는

   A = A + 1;

그러고 나서,

   *A = 10;

을 하면 되겠군요. 우후후... 간단......

────────────────────────────────────────

   #include <stdio.h>

   void main()
   {
       int A[5];

       A++;

       *A = 10;

       printf("%d", A[1]);
   }

 

   [결과]

   Error: "바보!!"

────────────────────────────────────────

그런데 컴파일러가 "이런 멍청한것!!" 하고 에러를 내보냅니다. 왜 컴파일러가 화를 낼까요?

여기서 배열 A가 포인터와 완전히 동일한게 아니라는 걸 확인할 수 있습니다.

배열 A는 무조건 [0]번째 원소에 대한 어드레스만을 가지고 있어야 합니다. [1], [2] 같은 다른 원소들에 대한 위치역시 [0]번째의 어드레스를 기반으로해서 계산하기 때문입니다. 따라서 그 기반이 되는 A의 어드레스 값은 절대 수정할 수 없습니다.

따라서 우리는 이렇게 해야합니다.

────────────────────────────────────────

   #include <stdio.h>

   void main()
   {
       int A[5];

       *A = 10;
       *(A+1) = 20;
       *(A+2) = 30;
       *(A+3) = 40;
       *(A+4) = 50;

       printf("%d %d %d %d %d", A[0], A[1], A[2], A[3], A[4]);
   }

 

   [결과]

   10 20 30 40 50

────────────────────────────────────────

A[0] ~ A[4] 에대한 배열의 각 원소를, 포인터 연산을 써서 *(A + 0) ~ *(A + 4)로 동일하게 접근할 수 있습니다.

반대로 얘기하면, A[n] 형식으로 배열의 각 원소를 다루는 것은 내부적으로 포인터 연산을 통해서 이뤄지고 있는 겁니다.

결론적으로 C에서의 "배열은 제한된 용도의 포인터" 라고도 볼 수 있는 겁니다.



3. 그럼 2차원 배열은?

그렇다면 2차원 배열도 마찬가지로 포인터를 이용해서 건드려 볼 수 있겠지요?

   int A[5][5];

만일 이렇게 이차원 배열을 선언했다고 합시다. 우리는 이걸 알기쉽게 표현하기 위해 보통 사각형의 테이블로 추상화해서 그리게 됩니다.

그러면 메모리에서는 어떻게 표현될까요?
불행히도 메모리 어드레스는 1차원의 개념밖에 없습니다.

그럼 5 x 5 배열, 총 25개의 공간을 어떻게 할당할까요?

일단 쉽게 생각해보면, 그냥 25개의 연속된 공간을 할당받는 겁니다.
말하자면 int A[25] 와 똑같이 할당받게 하자는 거죠.

그렇다고 한다면 *(A + 0) ~ *(A + 24)로 각 원소에 접근할 수 있을 겁니다.

그럼 한번 예제를 돌려 보기로 합니다.

1 ~ 25까지 숫자를 5 x 5 배열에 차례로 집어넣고, 그걸 다시 꺼내와서 출력하는 예제입니다.

────────────────────────────────────────

   #include <stdio.h>

   void main()
   {
       int A[5][5];
       int i, j;
       int count = 1;

       for (i=0; i < 5; i++)
           for (j=0; j < 5; j++)
               A[i][j] = count++;

       for (i = 0; i < 5*5; i++) {

           if( 0 == (i % 5) ) printf("");
           printf("%d ", *(A+i));

       }
   }
────────────────────────────────────────

자, 그럼 한번 실행해 볼까요?

어래? 이상한 숫자들만 나옵니다. 그래도 어디서 많이 본 숫자들 같죠?
이쯤되면 저게 어드레스 값이란걸 눈치채셨으리라 생각됩니다.
뭐가 문제일까요? 2차원 배열을 선언하는 문장을 다시 보겠습니다.

   int A[5][5];

사실 여기에서 뭔가 의문이 생겨야합니다. 왜 []를 2개 사용했을까요?
이렇게 하면 안되는 걸까요?

   int A[5, 5];

물론 이런 문법은 C에는 없습니다. (JAVA와 C#에는 있습니다)

그럼 []를 2번 사용한다는 것의 의미가 무엇일까요?

그건 바로 "Array of Array" 입니다.

C에서는 2차원 이상의 다차원 배열을 1차원 배열에 대한 중복된 정의로 구현합니다.
int A[5]에 대하여 다시 5개의 배열을 구성한 것이 바로 아래의 선언문입니다.

   int A[5][5];

사실 앞에서 연속으로 25개의 공간이 할당된다고 가정하는 것은 맞습니다. 다만 예제에서는 포인터 연산에서의 문제가 생긴겁니다.

   printf("%d", sizeof(A[0]);

이 문장의 결과는 어떻게 나올까요?

   [결과]

   20

int가 4 byte 이고, 이것이 5개 모이면 20 byte가 되죠. 바로 "int A[5]" 라는 선언에 대한 크기입니다.

앞에서 포인터 어드레스의 연산에서는 "n * sizeof(type)" 만큼 증감한다고 했습니다. 어마야... 그럼...

   A + 1

...을 하게되면 20 byte가 껑충 뛰어버리는 겁니다! 우리는 int의 크기만큼, 즉 4 byte 만큼 어드레스를 움직여야만 합니다. 그래야 우리가 원하는 데이터를 가져올 수가 있습니다.

그 렇다면 A가 아니라 A[0]를 기준으로해서 다시 어드레스를 계산하면 됩니다. A가 int A[5] 차원에서 어드레스를 계산하는 거라면, A[0]는 int A 차원에서 어드레스를 계산하게 되는 거죠. 따라서 A[0]에서 어드레스를 증가시키면 4 byte 단위로 증가하게 됩니다.

그럼 예제를 다시 만들어 보겠습니다.

────────────────────────────────────────

   #include <stdio.h>

   void main()
   {
       int A[5][5];
       int i, j;
       int count = 1;

       for (i=0; i < 5; i++)
           for (j=0; j < 5; j++)
               A[i][j] = count++;

       for (i = 0; i < 5*5; i++) {

           if( 0 == (i % 5) ) printf("");
           printf("%2d ", *(A[0]+i));
       }
   }

 

   [결과]











────────────────────────────────────────

이차원 배열 A가 가지는 어드레스와 A[0]가 갖는 어드레스는 동일한 값입니다.
아래 문장을 실행해 보면 바로 알 수 있습니다.

   printf("%d %d", A, A[0]);

그렇지만 타입 정보 때문에 +1을 시켰을 때 증가하는 크기가 달라지게 됩니다.
이것 역시 아래 코드로 확인해 볼 수 있습니다.

   printf("%d %d", A+1, A[0]+1);

때문에 여기서는 A[0]를 가지고 어드레스를 증가시켜야 했던 것이죠.

 

4. 2차원 배열을 좀 더 들여다 보자

그런데 좀전의 예제에서는 어째서 어드레스값이 나와버린걸까요?

   int A[5][5];

앞서, 이 선언이 Array of Array 라고 했습니다. 그래서 "A[0]" 자체도 하나의 배열입니다.
이건 1차원에서의 이런 관계와도 같은 겁니다.

   int A[5];

   A → 배열
   A[0] → 정수형 변수

이걸 2차원에서의 개념으로 옮겨가면...

   int A[5][5];

   A → 2차원 배열
   A[0] → 1차원 배열
   A[0][0] → 정수형 변수

1차원에서 배열 A의 값 자체를 출력하면, 그 배열에 대한 어드레스 값이 나오듯이, 2차원에서 배열 A[0]은 1차원 배열이므로, 이것의 값 역시 어드레스가 됩니다.

아래 예제로 2차원 배열에서 A[0] ~ A[4]까지의 값을 출력하면, 어드레스 값이 나오는 것을 확인할 수 있습니다.

────────────────────────────────────────

   #include <stdio.h>

   void main()
   {
       int A[5][5];
       int i;

       for (i=0; i < 5; i++)
           printf("%d ", A[i]);

   }

 

   [결과]








────────────────────────────────────────

정리하면, 2차원 배열일 경우에는...
배열의 이름 A 자체에는 2차원 배열에 대한 어드레스 값이 들어 있고, 이것을 통해서 A[0] ~ A[4]의 1차원 배열에 접근할 수 있습니다.

2차원 배열의 각 원소에 해당하는 1차원 배열 A[n]은, 다시 자신의 1차원 배열에 대한 어드레스 값을 가지고 있습니다.

이것을 그래프화 해보면 이렇습니다.

이것은 다른 의미로 보면 2중으로 연결된 포인터라고 볼 수도 있습니다.
A[i][j]에 접근하기 위해서 2개의 어드레스를 거쳐갔으니까요.
(A 자신의 어드레스 → A[i]의 어드레스 + j번째)

Array of Array 라는 건, 포인터 입장에서 보면 Pointer of Pointer'가 됩니다.

이처럼 2단계의 어드레스를 거친다는 점을 이용하면, 포인터 연산을 2중으로 처리함으로써 2차원 배열을 다룰 수도 있습니다.

그러면, A[1][2]에 접근하려고 한다면,

   *(A+1)

이렇게만 하면 A[1][0]에 대한 어드레스만 얻을 수 있습니다.
그렇기에 여기서 다시 포인터 연산을 해줍니다.

   *(*(A+1) + 2)

이렇게 하면 A[1][2]에 바로 접근할 수 있습니다.

이 방법으로 1~25까지 숫자를 2차원 배열에 넣고 출력하는 것을 다시 풀어보면 이렇게 됩니다.

────────────────────────────────────────

   // 1~25까지 숫자를 5x5 배열에 차례로 집어넣고, 그걸 다시 꺼내와서 출력하는 예제

   #include <stdio.h>

   void main()
   {
       int A[5][5];
       int i, j;
       int count = 1;

       for (i=0; i < 5; i++)
           for (j=0; j < 5; j++)
               A[i][j] = count++;

       for (i=0; i < 5; i++) {
           for (j=0; j < 5; j++)
              printf("%2d ", *(*(A+i) +j) );

           printf("");
      } 
   }

 

   [결과]











────────────────────────────────────────

마무리

배열을 설명하다 보니, 본의 아니게 2중 포인터 개념까지 나와버렸습니다.
2중 포인터 개념이 아직 이해가 안되더라도, 지금은 그냥 2차원 배열이 2중 포인터로 구현된다는 사실만 알아두십시요. 2중 포인터에 대해서는 나중에 한번 더 설명할 기회가 있을 겁니다.

다음 번에는 C에서의 "문자열" 처리에 관해서 얘기해 보겠습니다.

반응형

댓글