모두의 코드
씹어먹는 C 언어 - <12 - 2. 포인터는 영희이다! (포인터)>

작성일 : 2009-11-14 이 글은 72847 번 읽혔습니다.

이번 강좌에서는

  • 상수 포인터 (const int*, int* const)

  • 포인터의 덧셈, 뺄셈

  • 배열과 포인터와의 관계

  • [] 연산자

씹어먹는 C 언어

안녕하세요 여러분! 지난 시간에 포인터의 기본 중의 기본이라 할 수 있는 것 들에 배워보았습니다. 다시 정리해 보자면 포인터는 특정한 데이터의 메모리 상의 (시작) 주소값을 보관하는 변수 입니다.

제가 C 언어를 배우면서 포인터를 배울 때 가장 많이 든 생각은

근데 말야. 이거왜 배워?

이였습니다. 맞아요. 여러분들도 위와 같은 생각이 머리속에 끊임없이 맴돌 것 입니다. int a;int *p; 가 있을 때 pa 를 가리킨다고 하면 a = 3; 이라 하지 *p = 3; 과 같이 귀찮게 할 필요가 없잖아요. 하지만 나중에 가면 알겠지만 포인터는 C 언어에서 정말로 중요한 역할을 담당하게 될 것입니다. 포인터의 중요한 역할에 대해 지금 이야기 하는 것은 무리라고 생각합니다. 일단, 포인터가 뭔지만 알아 놓고 이걸 도대체 왜 배우는지에 대해선 나중에 이야기 하도록 합시다.

상수 포인터

이전에 11 - 1 강에서 상수에 대해 잠깐 언급한 것이 기억이 나시나요? 그 때 저는 어떠한 데이터를 상수로 만들기 위해 그 앞에 const 키워드를 붙여주면 된다고 했습니다. 예를 들어서

const int a = 3;

과 같이 값이 3 인 int 변수 a 를 상수로 정의할 수 있습니다. const 는 단순히 말해서 '이 데이터의 내용은 절대로 바뀔 수 없다' 라는 의미의 키워드 입니다. 따라서, 위 문장의 의미는 '이 int 변수 a 의 값은 절대로 바뀌면 안된다!!!' 가 됩니다. 위와 같이 정의한 상수 a 를 아래 문장에

a = 4;

와 같이 하려고 해도 컴파일 시에 오류가 발생하게 됩니다. 왜냐하면 a 는 상수로 선언이 되어 있으므로 값이 절대로 변경될 수 없기 때문이죠. 심지어 '값이 변경될 가능성이 있는 문장' 조차 허용되지 않습니다. 예를 들어

a = 3;

이라고 한다면, a 의 값은 이미 3 이므로 a 의 값은 바뀌지 않습니다. 그런데 왠일? 컴파일 해보면 오류가 출력됩니다. 왜냐하면 위 문장은 a 의 값이 바뀔 '가능성' 이 있기 때문이죠. 즉, 컴파일러는 a 에 무슨 값이 들어가 있는지 신경 쓰지 않습니다. 그냥 무조건 가능성이 있다면 오류를 출력합니다.

여러분은 도대체 왜 상수를 사용하는지 의문을 가질 것 입니다. 하지만 상수는 프로그래밍 상에서 프로그래머들의 실수를 줄여주고, 실수를 했다고 해도 실수를 잡아내는데 중요한 역할을 하고 있습니다. 예를 들어 아래와 같은 문장을 봅시다.

const double PI = 3.141592;

double 형 변수 PI3.141592 라는 값을 가지게 선언하였습니다. 왜 이렇게 해도 되냐면 실제로 PI 값은 절대로 바뀌지 않는 상수 이기 때문이죠. 따라서, 프로그래머가 밤에 졸면서 코딩을 하다가 아래와 같이

PI = 10;

PI 의 값을 문장을 집어 넣었다고 해도 컴파일 시 오류가 발생하여 프로그래머는 이를 고칠 수 있게 됩니다. 반면에 PI 를 그냥 double 형 변수로 선언했다고 해봅시다.

double PI = 3.141592;

그렇다면 프로그래머가 아래와 같은 코드를 잠결에 집어 넣었다면

PI = 10;

컴파일러는 이를 오류로 처리하지 않습니다. 이는 엄청나게 큰일이 아닐 수 없죠. 만일 고객들에게 원의 넓이를 계산하는 프로그램을 만들어 주었는데 잘못해서 이상한 값이 나오면 어떻겠습니까? 물론 위와 같이 간단한 오류는 잡아내기 쉽지만 프로그램이 커지만 커질 수록 위와 같은 오류를 잡아내는 것은 여간 힘든 일이 아닙니다. 따라서, 우리는 '절대로 바뀌지 않을 것 같은 값에는 무조건 const 키워드를 붙여주는 습관' 을 기르는 것이 중요합니다.

아무튼. 이번에는 포인터에도 const 를 붙일 수 있는지 생각해 봅시다.

/* 상수 포인터? */
#include <stdio.h>
int main() {
  int a;
  int b;
  const int* pa = &a;

  *pa = 3;  // 올바르지 않은 문장
  pa = &b;  // 올바른 문장
  return 0;
}

컴파일 해보면 오류가 발생합니다.

컴파일 오류

error C2166: l-value가 const 개체를 지정합니다.

일단, 위 오류가 왜 발생하였는지에 대해 이야기 하기 앞서서 아래 문장이 무슨 의미를 가지는지 살펴 봅시다.

const int* pa =
  &a;  // int* pa 와 같이 정의해도 int *pa 와 같다는 사실은 다 알고 있죠?

여러분은 위 문장을 보면 다음과 같은 생각이 떠오를 것입니다. "저 포인터는 const int 형을 가리키는 포인터인데, 어떻게 int 형 변수 a 의 주소값이 대입 될 수 있지? 그러면 안되는 거 아니야?". 하지만, 제가 앞에서 강조해 왔듯이 const 라는 키워드는 이 데이터의 값은 절대로 바뀌면 안된다 라고 일러주는 키워드라고 하였습니다.

다시 말해, const int a 라는 변수는 그냥 int 형 변수 a 인데 값이 절대로 바뀌면 안되는 변수일 뿐입니다. 따라서, const int a 변수도 그냥 int 형이라 말할 수 있습니다. (다만 '변'수가 아닐 뿐)

따라서 const int* 의 의미는 const int 형 변수를 가리킨다는 것이 아닙니다. int 형 변수를 가리키는데 그 값을 절대로 바꾸지 말라 라는 의미이죠. 즉, pa 는 어떠한 int 형 변수를 가리키고 있습니다. 그런데 const 가 붙었으므로 pa 가가리키는 변수의 값은 절대로 바뀌면 안되게 됩니다.

여기서 pa 라는 부분을 강조한 이유는 a 자체는 변수 이므로 값이 자유롭게 변경될 수 있기 때문입니다. 하지만 pa 를 통해서 a 를 간접적으로 가리킬 때 에는 컴퓨터가 아, 내가 const 인 변수를 가리키고 있구나 로 생각하기 때문에(const int* 로 포인터를 정의하였으므로) 값을 바꿀 수 없게 됩니다.

결과적으로 아래의 문장은 오류를 출력합니다.

물론 a = 3; 과 같은 문장은 오류를 출력하지 않습니다. 앞에서도 말했듯이 변수 a 자체는 const 가 아니기 때문이죠.

pa = &b;  // 올바른 문장

그렇다면 위 문장은 옳은 문장 입니다. 왜 일까요? (아마 당연하다고 생각하면 여러분은 훌륭한 학생들 입니다) 이는 아래 예제와 함께 설명하도록 하겠습니다.

/* 상수 포인터? */
#include <stdio.h>
int main() {
  int a;
  int b;
  int* const pa = &a;

  *pa = 3;  // 올바른 문장
  pa = &b;  // 올바르지 않은 문장

  return 0;
}

역시 컴파일 해보면

컴파일 오류

 error C2166: l-value가 const 개체를 지정합니다.

앞서 보았던 오류와 동일한 오류가 뜹니다. 그런데 위치가 다릅니다. 앞에서는 위 문장에서 오류가 발생했는데 이번엔 아래에서 발생합니다. 일단, 포인터의 정의 부분 부터 이야기 해봅시다.

int* const pa = &a;

차근차근 봐 보면, 우리는 int* 를 가리키는 pa 라는 포인터를 정의하였습니다. 그런데 이번에는 const 키워드가 int* 앞에 있는 것이 아니라 int*pa 사이에 놓이고 있습니다. 뭐지? 하지만 이 것은 const 키워드의 의미를 그대로 생각해 보면 간단합니다. pa 의 값이 바뀐 안된다는 것이지요.

그런데 제일 처음에 포인터를 배울 때 강조했듯이, 포인터에는 가리키는 데이터의 주소값, 즉 위 경우 a 의 주소값이 pa 저장되는 것이지요. 따라서, 이 paconst 라는 의미는 pa 의 값이 절대로 바뀔 수 없다는 것인데, pa 는 포인터가 가리키는 변수의 주소값이 들어 있으므로 결과적으로 pa 가 처음에 가리키는 것 (a) 말고 다른 것은 절대로 건드릴 수 없다는 것 입니다.

    pa = &b; // 올바르지 않은 문장

결론적으로 위 문장은 오류를 뿜게 됩니다. 왜냐하면 pa 가 다른 변수를 가리키기 때문이죠 (즉 pa 에 저장된 주소값을 바꾸므로) 반면에 위의 예제에서 오류가 났던 문장은 올바르게 돌아갑니다.

*pa = 3;  // 올바른 문장

왜냐하면 pa 가 가리키는 값을 바꾸면 안된다는 말은 안했기 때문이죠. (그냥 int*)

한 번 위에 나와있던 것을 모두 합쳐 보면

/* 상수 포인터? */
#include <stdio.h>
int main() {
  int a;
  int b;
  const int* const pa = &a;

  *pa = 3;  // 올바르지 않은 문장
  pa = &b;  // 올바르지 않은 문장

  return 0;
}

와 같이 되겠지요. 어때요? 쉽죠?

포인터의 덧셈

이번에는 포인터의 덧셈과 뺄셈에 대해서 다루어 보도록 하겠습니다. 앞에서도 강조하였지만 지금 하는 작업들이 무의미해 보이고 쓸모 없어 보이지만 나중에 정말로 중요하게 다루어 집니다. 조금만 힘내세요 (아마도 C 언어에서 가장 재미 없는 부분일듯.)

/* 포인터의 덧셈 */
#include <stdio.h>
int main() {
  int a;
  int* pa;
  pa = &a;

  printf("pa 의 값 : %p \n", pa);
  printf("(pa + 1) 의 값 : %p \n", pa + 1);

  return 0;
}

성공적으로 컴파일 해보면

실행 결과

pa 의 값 : 0x7ffd6a32fc4c 
(pa + 1) 의 값 : 0x7ffd6a32fc50 

여러분의 출력 결과는 위에 나온 결과와 다를 수 있습니다. 다만, 두 수의 차이는 4 일 것입니다. (16 진수임에 유의하세요; 50 에서 4c 를 빼면 4!)

아마 여러분은 출력된 결과를 보면서 깜짝 놀랐을 것입니다. 우리는 분명히

printf("(pa + 1) 의 값 : %p \n", pa + 1);

에서 pa + 1 의 값을 출력하라고 명시하였습니다. 제가 앞에서도 이야기 하였듯이 pa 에는 자신이 가리키는 변수의 주소값이 들어갑니다. 따라서, pa + 1 을 하면 0x7ffd6a32fc4c 에 1 이 더해진 0x7ffd6a32fc4d 가 아니라, 4 가 더해진 0x7ffd6a32fc50 이 출력되었습니다. 이게 도대체 무슨 일입니까? 0x7ffd6a32fc4c + 1 = 0x7ffd6a32fc50 이라고요?

위 해괴한 계산 결과를 해결하기 앞서, 우리는 포인터의 형이 int* 라는 것을 알 수 있었습니다. 그런데 int 가 4 바이트 이니까...설마?

일단, 위 추측을 확인해보기 위해 int 포인터 말고도 크기가 다른 char 이다 double 등에 대해서도 해봅시다.

/* 과연? */
#include <stdio.h>
int main() {
  int a;
  char b;
  double c;
  int* pa = &a;
  char* pb = &b;
  double* pc = &c;

  printf("pa 의 값 : %p \n", pa);
  printf("(pa + 1) 의 값 : %p \n", pa + 1);
  printf("pb 의 값 : %p \n", pb);
  printf("(pb + 1) 의 값 : %p \n", pb + 1);
  printf("pc 의 값 : %p \n", pc);
  printf("(pc + 1) 의 값 : %p \n", pc + 1);

  return 0;
}

성공적으로 컴파일 후 실행해 보면

실행 결과

pa 의 값 : 0x7ffcf64a2e04 
(pa + 1) 의 값 : 0x7ffcf64a2e08 
pb 의 값 : 0x7ffcf64a2e03 
(pb + 1) 의 값 : 0x7ffcf64a2e04 
pc 의 값 : 0x7ffcf64a2e08 
(pc + 1) 의 값 : 0x7ffcf64a2e10 

여러분의 출력 결과는 위에 나온 결과와 다를 수 있습니다.

우왕. 우리의 예상과 정확하게 맞아 떨어졌습니다. pb 의 경우 1 이 더해졌고, pc 의 경우 8 이 더해졌습니다. 그런데, char 은 1 바이트, double 은 8 바이트 이므로 모두 우리가 예상한 결과와 일치합니다. 놀랍군요. 하지만 머리 한 켠에는 또다른 의문이 남습니다. 왜 하라는 대로 안하고 포인터가 가리키는 형의 크기 만큼 더할까요. 사실 이에 대한 해답은 뒤에 나옵니다.

훌륭한 학생이라면 여러가지 모험을 해볼 것 입니다. 예를 들어 포인터의 뺄셈은 허용되는지, 포인터 끼리 더해도 되는지 등등.. 말이죠. 우리도 한 번 궁금증을 해결해 봅시다.

일단 직관적으로 포인터의 뺄셈은 허용될 것 같습니다. 왜냐하면 뺄셈은 본질적으로 덧셈과 다를 바 없기 때문이죠. (1 - 1 = 1 + (-1)) 아무튼 해 보면 덧셈과 유사한 결과가 나타납니다.

/* 포인터 뺄셈 */
#include <stdio.h>
int main() {
  int a;
  int* pa = &a;

  printf("pa 의 값 : %p \n", pa);
  printf("(pa - 1) 의 값 : %p \n", pa - 1);

  return 0;
}

성공적으로 컴파일 후 실행 해보면

실행 결과

pa 의 값 : 0x7ffe4f4fa47c 
(pa - 1) 의 값 : 0x7ffe4f4fa478 

여러분의 출력 결과는 위에 나온 결과와 다를 수 있습니다. 역시 우리의 예상대로 4 가 빼졌습니다.

/* 포인터끼리의 덧셈 */
#include <stdio.h>
int main() {
  int a;
  int *pa = &a;
  int b;
  int *pb = &b;
  int *pc = pa + pb;

  return 0;
}

아마 컴파일 해보면 아래와 같은 오류를 만날 수 있습니다.

컴파일 오류

error C2110: '+' : 두 포인터를 더할 수 없습니다.

왜 C 에서는 두 포인터끼리의 덧셈을 허용하지 않는 것일까요? 사실, 포인터끼리의 덧셈은 아무런 의미가 없을 뿐더러 필요 하지도 않습니다. 두 변수의 메모리 주소를 더해서 나오는 값은 이전에 포인터들이 가리키던 두 개의 변수와 아무런 관련이 없는 메모리 속의 임의의 지점 입니다. 아무런 의미가 없는 프로그램 상에 상관없는 지점을 말이죠. 무언가, 설명이 불충분한 느낌이 들지만 아무튼 포인터 끼리의 덧셈은 아무런 의미가 없기 때문에 C 언어에선 수행할 수 없습니다. 그렇다면, 포인터에 정수를 더하는 것은 왜 되는 것일까요. 아까도 말했듯이 이에 대해선 아래에서 설명해드리겠습니다.

그런데 한 가지 놀라운 점은 포인터끼리의 뺄셈은 가능하다는 것입니다. 왜 그런지에 대한 설명은 나중에 합시다.

/* 포인터의 대입 */
#include <stdio.h>
int main() {
  int a;
  int* pa = &a;
  int* pb;

  *pa = 3;
  pb = pa;

  printf("pa 가 가리키고 있는 것 : %d \n", *pa);
  printf("pb 가 가리키고 있는 것 : %d \n", *pb);

  return 0;
}

성공적으로 컴파일 해보면

실행 결과

pa 가 가리키고 있는 것 : 3 
pb 가 가리키고 있는 것 : 3 

와 같이 나옵니다. 뭐 당연한 일이지요.

pb = pa;

부분에서 pa 에 저장되어 있는 값 (즉, pa 가 가리키고 있는 변수의 주소값) 을 pb 에 대입하였습니다. 따라서 pbpa 가 가리키던 것의 주소값을 가지게 되는 것이지요. 결과적으로 pbpa 모두 a 를 가리키게 됩니다. 주의해야 될 점은 papb 가 형이 같아야 한다는 점 입니다. 다시 말해 paint*pbint* 여야 합니다. 만일 형이 다르다면 형변환을 해주어야 하는데 이에 대한 이야기는 나중에 합시다.

배열과 포인터

아마 이 단원을 읽다 보면 쇼크를 받을 지도 모르므로 심장이 약하신 분들은 의사와 함께 하십시오.(참고로 저의 경우 많이 놀라서 잠을 잘 못잤습니다)

제가 C 언어를 배우면서 가장 감탄하고도 쇼킹했던 부분이 바로 여기였습니다. 물론, 모든 사람들이 그다지 놀라워 하는 것은 아니지만 저한테는 신선한 충격이였습니다. 아마 이 단원을 배운다면 앞서 '포인터의 연산은 왜 이따구로 하는 거야!' 에 대한 답안을 찾을 수 있을 것 입니다.

이전 강좌에서 (11 강) 저는 여러분에게 배열에 대해 이야기 했었습니다. 기억을 상기해보자면, 배열은 변수가 여러개 모인 것으로 생각할 수 있다 라고 이야기 했었지요. 그런데 말이죠. 또다른 놀라운 특징이 있습니다. 바로 배열들의 각 원소는 메모리 상에 연속되게 놓인 다는 점입니다. 뭐, 놀랍지 않다면 말고요. 어쨋든,

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

이라는 배열을 정의한다면 메모리 상에서 다음과 같이 나타납니다.

10 개의 방이 있고 각 방에 1 부터 10 까지 들어가 있습니다. 물론 각 방의 주소값은 다 있죠

즉, 위와 같이 메모리 상에 연속된 형태로 나타난다는 점이지요. 한 개의 원소는 int 형 변수이기 때문에 4 바이트를 차지하게 됩니다. 물론, 위 사실을 믿지 못하시는 분들은 아래와 같이 컴퓨터를 통해 직접 확인해 볼 수 있습니다.

/* 배열의 존재 상태? */
#include <stdio.h>
int main() {
  int arr[10] = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
  int i;

  for (i = 0; i < 10; i++) {
    printf("arr[%d] 의 주소값 : %p \n", i, &arr[i]);
  }
  return 0;
}

성공적으로 컴파일 하면

실행 결과

arr[0] 의 주소값 : 0x7ffeb5683890 
arr[1] 의 주소값 : 0x7ffeb5683894 
arr[2] 의 주소값 : 0x7ffeb5683898 
arr[3] 의 주소값 : 0x7ffeb568389c 
arr[4] 의 주소값 : 0x7ffeb56838a0 
arr[5] 의 주소값 : 0x7ffeb56838a4 
arr[6] 의 주소값 : 0x7ffeb56838a8 
arr[7] 의 주소값 : 0x7ffeb56838ac 
arr[8] 의 주소값 : 0x7ffeb56838b0 
arr[9] 의 주소값 : 0x7ffeb56838b4 

와 같이 나타납니다. 여러분의 결과와 주소값은 약간 다를 수 있지만, 어쨋든 4 씩 증가하면 된 것입니다.

아마 여기쯤 왔다면 여러분의 머리를 스쳐지나가는 생각이 들 것입니다! 아! 포인터로도 배열의 원소에 쉽게 접근이 가능하겠구나! (이 생각이 떠오르지 않는 사람은 아마 이 글을 다시 처음부터 읽으셔야 합니다.) 배열의 시작 부분을 가리키는 포인터를 정의한 뒤에 포인터에 1 을 더하면 그 다음 원소를 가리키겠군! 그리고 2 를 더한 그 다음 다음 원소를 가리킨다!!

위와 같은 일이 가능한 이유는 포인터는 자신이 가리키는 데이타의 '형' 의 크기를 곱한 만큼 덧셈을 수행하기 때문이죠. 즉 p 라는 포인터가 int a; 를 가리킨다면 p + 1 을 할 때 p 의 주소값에 사실은 1*4 가 더해지고, p + 3 을 하면 p 의 주소값에 3 * 4 인 12 가 더해진다는 것입니다.

한 번 이 아이디어를 적용시켜서 배열의 원소를 가리키는 포인터를 만들어봅시다.

/* 과연? */
#include <stdio.h>
int main() {
  int arr[10] = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
  int* parr;
  int i;
  parr = &arr[0];

  for (i = 0; i < 10; i++) {
    printf("arr[%d] 의 주소값 : %p ", i, &arr[i]);
    printf("(parr + %d) 의 값 : %p ", i, (parr + i));

    if (&arr[i] == (parr + i)) {
      /* 만일 (parr + i) 가 성공적으로 arr[i] 를 가리킨다면 */
      printf(" --> 일치 \n");
    } else {
      printf("--> 불일치\n");
    }
  }
  return 0;
}

성공적으로 컴파일 하였다면

실행 결과

arr[0] 의 주소값 : 0x7ffedbe31530 (parr + 0) 의 값 : 0x7ffedbe31530  --> 일치 
arr[1] 의 주소값 : 0x7ffedbe31534 (parr + 1) 의 값 : 0x7ffedbe31534  --> 일치 
arr[2] 의 주소값 : 0x7ffedbe31538 (parr + 2) 의 값 : 0x7ffedbe31538  --> 일치 
arr[3] 의 주소값 : 0x7ffedbe3153c (parr + 3) 의 값 : 0x7ffedbe3153c  --> 일치 
arr[4] 의 주소값 : 0x7ffedbe31540 (parr + 4) 의 값 : 0x7ffedbe31540  --> 일치 
arr[5] 의 주소값 : 0x7ffedbe31544 (parr + 5) 의 값 : 0x7ffedbe31544  --> 일치 
arr[6] 의 주소값 : 0x7ffedbe31548 (parr + 6) 의 값 : 0x7ffedbe31548  --> 일치 
arr[7] 의 주소값 : 0x7ffedbe3154c (parr + 7) 의 값 : 0x7ffedbe3154c  --> 일치 
arr[8] 의 주소값 : 0x7ffedbe31550 (parr + 8) 의 값 : 0x7ffedbe31550  --> 일치 
arr[9] 의 주소값 : 0x7ffedbe31554 (parr + 9) 의 값 : 0x7ffedbe31554  --> 일치 

정확히 모두 일치가 나옵니다. 위 소스코드가 이해가 안되는 분들이 있을 까봐 살짝 설명을 드리기는 하겠습니다.

parr = &arr[0];

parr 이라는 int 형을 가리키는 포인터는 arr[0] 이라는 int 형 변수를 가리킵니다. (배열의 각 원소는 하나의 변수로 생각할 수 있다는 사실은 까먹지 않았죠?)

printf("arr[%d] 의 주소값 : %p ", i, &arr[i]);
printf("(parr + %d) 의 값 : %p ", i, (parr + i));

이제, arr[i] 의 주소값과 (parr + i) 의 값을 출력해봅니다. 만일 parr + i 의 값이 arr[i] 의 주소값과 같다면 하단의 if-else 에서 일치가 출력되고 다르다면 불일치가 출력되게 됩니다. 그런데, 이미 예상하고 있던 바이지만 parrint 형이므로 + i 를 하면 주소값에는 사실상 4*i 가 더해지게 되는 것이지요. 이 때 arr[i] 의 주소값도 i 가 하나씩 커질 때 마다 4 씩 증가하므로 (int 형 배열이므로) 결과적으로 모든 결과가 일치하게 되는 것 입니다.

이렇게 포인터에 정수를 더하는 것 만으로도 배열의 각 원소를 가리킬 수 있습니다. 그렇다면 * 를 이용하여 원소들과 똑같은 역할을 할 수 있게 되겠군요. 마치 아래 예제 처럼 말이지요.

/* 우왕 */
#include <stdio.h>
int main() {
  int arr[10] = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
  int* parr;

  parr = &arr[0];

  printf("arr[3] = %d , *(parr + 3) = %d \n", arr[3], *(parr + 3));
  return 0;
}

성공적으로 컴파일 하였다면

실행 결과

arr[3] = 4 , *(parr + 3) = 4 

와 같이 동일하게 접근할 수 있게 됩니다.

parr + 3 을 수행하면, arr[3] 의 주소값이 되고, 거기에 * 를 붙여주면 * 의 연산자의 역할이 '그 주소값에 해당하는 데이터를 의미해라!' 라는 뜻이므로 *(parr + 3)arr[3] 과 동일하게 된다는 것입니다. 어때요? 놀랍지요. 포인터의 덧셈이 왜 그렇게 수행되는지 속 시원하게 해결되는 것 같나요?

배열의 이름의 비밀

아마 여러분들 중 대다수는 배열을 처음 배울 때 다음과 같은 실수를 하신 경험이 있을 것 입니다. (나만 그런가?)

#include <stdio.h>
int main() {
  int arr[3] = {1, 2, 3};
  printf("%d", arr);
}

그러곤 1 도, 2 도, 3 도, 아닌 이상한 값이 나오는 것을 보고 당황하셨겠죠. 그런데, 놀랍게도 그 때 출력되는 값은 아래와 같습니다.

#include <stdio.h>
int main() {
  int arr[3] = {1, 2, 3};

  printf("arr 의 정체 : %p \n", arr);
  printf("arr[0] 의 주소값 : %p \n", &arr[0]);

  return 0;
}

성공적으로 컴파일 하면

실행 결과

arr 의 정체 : 0x7fff1e868b1c 
arr[0] 의 주소값 : 0x7fff1e868b1c 

와 같이 나옵니다. 와우! 놀랍게도 arrarr[0] 의 주소값이 동일합니다.

따라서 배열에서 배열의 이름은 배열의 첫 번째 원소의 주소값을 나타내고 있다는 사실을 알 수 있습니다. 그렇다면 배열의 이름이 배열의 첫 번째 원소를 가리키는 포인터라고 할 수 있을까요? 아닙니다!

주의 사항

이 부분은 (저를 포함한) 많은 사람들이 헷갈렷던 부분들 중 하나 입니다. 포인터를 갓 배운 상태에서 읽어보면 이해가 잘 가지 않을 수 도 있으니, 나중에 포인터와 조금 친숙해진다면 꼭 다시 읽어보는 것을 추천합니다.

배열은 배열이고 포인터는 포인터이다.

예를 들어서 다음과 같이 sizeof 를 사용하는 코드를 살펴봅시다. 기억을 상기해보자면 sizeof 는 크기를 알려주는 연산자 입니다.

#include <stdio.h>
int main() {
  int arr[6] = {1, 2, 3, 4, 5, 6};
  int* parr = arr;

  printf("Sizeof(arr) : %d \n", sizeof(arr));
  printf("Sizeof(parr) : %d \n", sizeof(parr));
}

성공적으로 컴파일 하였다면

실행 결과

Sizeof(arr) : 24 
Sizeof(parr) : 8 

와 같이 나옵니다. 재미 있게도

  printf("Sizeof(arr) : %d \n", sizeof(arr));

sizeofarr 자체에 그대로 썼을 경우 배열의 실제 크기 가 나옵니다. 우리의 arr 배열에는 int 원소 6 개가 있으므로 크기가 24 가 되겠지요. 반면에 parrsizeof 연산자를 사용하였을 경우

  printf("Sizeof(parr) : %d \n", sizeof(parr));

배열의 자체의 크기가 아니라 그냥 포인터의 크기를 알려줍니다 (64 비트 컴퓨터 이므로 출력된 것 처럼 8 바이트 겠지요).

따라서 배열의 이름과, 첫 번째 원소의 주소값은 엄밀히 다른 것 인 것입니다. 그렇다면 도대체 왜 두 값을 출력 했을 때 같은 값이 나왔을까요?

그 이유는 C 언어 상에서 배열의 이름이 sizeof 연산자나 주소값 연산자(&)와 사용될 때 (예를 들어 &arr) 경우를 빼면, 배열의 이름을 사용시 암묵적으로 첫 번째 원소를 가리키는 포인터로 타입 변환되기 때문입니다.

그렇다면 이제 왜 아래 코드에서 배열의 시작 원소의 주소값이 나왔는지 이해가 가시나요?

#include <stdio.h>
int main() {
  int arr[3] = {1, 2, 3};

  printf("arr 의 정체 : %p \n", arr);
  printf("arr[0] 의 주소값 : %p \n", &arr[0]);

  return 0;
}

arrsizeof 랑도, 주소값 연산자랑도 사용되지 않았기에, arr 은 첫 번째 원소를 가리키는 포인터로 타입 변환되었기에, &arr[0] 와 일치하게 됩니다.

[] 연산자의 역할

여러분들 중에서 많은 분들은 [] 가 연산자였다는 사실을 보고 깜짝 놀랐을 것 입니다. 그런데, 4 강에서 연산 순위에 대해 이야기 하였을 때 눈썰미가 좋으신 분들은 [] 가 연산자로 나와있음을 보셨을 것입니다.

www.winapi.com 에서 가져온 자료 입니다.

그런데, 우리는 앞서 포인터 연산이 어떻게 돌아가는지 배웠기 때문에 [] 연산자의 역할을 대충 짐작할 수 있습니다.

/* [] 연산자 */
#include <stdio.h>
int main() {
  int arr[5] = {1, 2, 3, 4, 5};

  printf("a[3] : %d \n", arr[3]);
  printf("*(a+3) : %d \n", *(arr + 3));
  return 0;
}

성공적으로 컴파일 했다면

실행 결과

a[3] : 4 
*(a+3) : 4 

음... 이미 앞에서 다룬 내용을 모두 이해했더라면 위 정도쯤은 쉽게 이해할 수 있을 것입니다. 사실 컴퓨터는 C 에서 [] 라는 연산자가 쓰이면 자동적으로 위 처럼 형태로 바꾸어서 처리하게 됩니다. 즉, 우리가 arr[3] 이라 사용한 것은 사실 *(arr + 3) 으로 바뀌어서 처리가 된다는 뜻이지요.

그리고 arr+ 연산자와 사용되기 때문에 앞서 말했듯이 첫 번째 원소를 가리키는 포인터 로 변환 되어서 arr + 3 이 포인터 덧셈을 수행하게 됩니다. 그리고 이는 배열의 4 번째 원소를 가리키게 되겠지요.

따라서 다음과 같이 신기한 연산도 가능합니다.

/* 신기한 [] 사용 */
#include <stdio.h>
int main() {
  int arr[5] = {1, 2, 3, 4, 5};

  printf("3[arr] : %d \n", 3 [arr]);
  printf("*(3+a) : %d \n", *(arr + 3));
  return 0;
}

성공적으로 컴파일 하면

실행 결과

3[arr] : 4 
*(3+a) : 4 

3[arr] 은 무언가 조금 이상한 표현 입니다. 사실 이렇게 사용한다면 가독성도 떨어지고 한 번에 이해도 되지 않기에 대부분의 프로그래머들은 arr[3] 으로 사용할 것입니다. 하지만, 앞에서도 [] 는 연산자로 3[arr]*(3+arr) 로 바꿔주기 때문에 arr[3] 과 동일한 결과를 출력할 수 있게 되지요.

포인터의 정의

앞에서 말하기를 int 를 가리키는 포인터를 정의하기 위해 다음의 두 문장을 모두 사용할 수 있다고 했습니다.

int* p;
int *p;

그런데 말이죠. 제 강좌 말도 다른 곳에서 C 언어를 공부했던 사람들이라면 아래와 같은 형식을 훨씬 많이 쓴다는 사실을 알 수 있었을 것입니다.

int *p;

왜 일까요? 우리가 int 형 변수를 여러개 한 번에 선언하려 했을 때 int a,b,c,d; 라 하잖아요. 포인터 변수를 여러개 선언 하려면 아래와 같이 해야 합니다.

int *p, *q, *r;

물론

int *p, *q, *r;

게 해도 됩니다. 다만,

int* p;

꼴로 한다면 다음과 같이 실수 할 확률이 매우 커지게 됩니다. 왜냐하면 아래와 같이 한다면

int *p, q, r;

pint 를 가리키는 포인터 이고, q, r 은 평범한 int 형 변수가 됩니다. 따라서, 앞으로 저는 제 강좌에서 모든 포인터들은

int *p;

꼴로 선언 하도록 하겠습니다.

생각해 볼 문제

문제 1

int arr[3][3]; 과 같은 배열은 내부적으로 어떻게 처리되는지 생각해보세요 (난이도 : 中)

문제 2

int* arr[3]; 과 같은 배열이 가지는 의미는 무엇일까요? (난이도 : 中)

강좌를 보다가 조금이라도 궁금한 것이나 이상한 점이 있다면 꼭 댓글을 남겨주시기 바랍니다. 그 외에도 강좌에 관련된 것이라면 어떠한 것도 질문해 주셔도 상관 없습니다. 생각해 볼 문제도 정 모르겠다면 댓글을 달아주세요.

현재 여러분이 보신 강좌는 <씹어먹는 C 언어 - <12 - 2. 포인터는 영희이다! (포인터)>> 입니다. 이번 강좌의 모든 예제들의 코드를 보지 않고 짤 수준까지 강좌를 읽어 보시기 전까지 다음 강좌로 넘어가지 말아주세요

댓글이 173 개 있습니다!
프로필 사진 없음
강좌에 관련 없이 궁금한 내용은 여기를 사용해주세요

    댓글을 불러오는 중입니다..