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

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

이번 강좌에서는

  • 포인터에 대한 완벽한 이해

  • *, & 단항 연산자의 의미

씹어먹는 C 언어

우왕~ 안녕하세요 여러분. 아마 C 언어를 배웠거나 배우고 있는 사람들은 포인터에 대해 익히 들어 보셨을 것 입니다. 이해하기 힘들기로 악명 높은 그 포인터를 말이죠. 하지만, 저와 함께 한다면 큰 무리 없이 배우 실 수 있을 것이라 생각됩니다.

포인터를 이해하기 앞서

앞서 3 강에서 이야기 하였지만 모든 데이터들은 메모리 상에 특정한 공간에 저장 되어 있습니다. 우리는 앞으로 편의를 위해, 메모리의 특정한 공간을 '방' 이라고 하겠습니다. 각 방에는 데이터들이 들어가게 되는 것 입니다.

한 방의 크기는 보통 1 바이트 라고 정의됩니다. 우리가 만약 4 바이트 짜리 int 형 변수를 정의한다면메모리 상의 4 칸을 차지하게 됩니다.

프로그램 작동 시 컴퓨터는 여러 방들에 있는 데이터를 필요로 하게 됩니다. 따라서, 어떤 방에서 데이터를 가져올 지 구분하기 위해 각 방에 고유의 주소(address) 를 붙여 주었습니다. 우리가 아파트에서 각 집들을 호수로 구분하는 것 처럼 말입니다.

예를 들어 우리가 아래와 같은 int 변수 a 를 정의하였다면 특정한 방에 아래 그림 처럼 변수 a 가 정의됩니다.

int a = 123;  // 메모리 4 칸을 차지하게 한다.

메모리에 123 이란 수가 있고 이 수는 메모리에 0x152839 (앞에 0x 는 이 수가 16 진수로 표시되었다는 것을 의미해요 에 위치

이 때, 0x152839 는 제가 아무렇게나 정한 이 방의 시작 주소 입니다. 참고로, 0x 가 뭐냐고 물어보는 사람들이 있을 텐데, 이전 강좌에서도 이야기 하였지만 16 진수라고 표시한 것 입니다. 즉, 16 진수로 152839 (10 진수로 1386553) 라는 위치에서 부터 4 바이트의 공간을 차지하며 123 이라는 값이 저장되어 있게 하라는 뜻이지요.

그렇다면 아래와 같은 문장은 어떻게 수행 될까요?

a = 10;

사실 컴파일러는 위 문장을 아래와 같이 바꿔주게 됩니다.

메모리 0x152839 위치에서 부터 4 바이트의 공간에 있는 데이터를 10 으로 바꾸어라!

결과적으로, 컴퓨터 내부에서는 올바르게 수행되겠지요.

참고적으로 말하는 이야기 이지만 현재 (아마 이 블로그에 접속하는 사람들 중 99% 이상이) 많은 사람들은 32 비트 운영체제를 사용하고 있습니다.

이 32 비트에서 작동되는 컴퓨터들은 모두 주소값의 크기가 32 비트 (즉, 4 바이트.. 까먹었다면 2 - 3 강 참조) 로 나타내집니다. 즉 주소값이 0x00000000 ~ 0xFFFFFFFF 까지의 값을 가진다는 것이지요.

어랏! 조금 똑똑하신 분들이라면 32 비트로 사용할 수 있는 주소값의 가지수는 2 의 32 승 바이트, 즉 RAM 은 최대 4 GB 까지 밖에 사용할 수 없다는 사실을 알 수 있습니다. 맞습니다. 이 때문에 32 비트 운영체제에서는 RAM 의 최대 크기가 4 GB 로 제한되지요(즉, 돈을 많이 들여서 RAM 을 10GB 로 만들어도 컴퓨터는 4 GB 까지 밖에 인식하지 못합니다. 어찌 이렇게 슬플수가..)

여기까지는 상당히 직관적이고 단순해서 이해하기 쉬웠을 것 입니다. 그런데 C 를 만든 사람은 아주 유용하면서도 골때리는 것을 하나 새롭게 만들었습니다. 바로 '포인터(pointer)' 입니다. 영어를 잘하는 분들은 이미 아시겠지만 '포인터' 라는 단어의 뜻이 '가리키는 것(가르켜지는 대상체를 말하는 것이 아닙니다)' 이란 의미를 가지고 있습니다.

사실, 포인터는 우리가 앞에서 보았던 intchar 변수들과 다른 것이 전혀 아닙니다. 포인터도 '변수' 입니다. int 형 변수가 정수 데이터, float 형 변수가 실수 데이터를 보관했던 것 처럼, 포인터도 특정한 데이터를 보관하는 '변수' 입니다. 그렇다면 포인터는 무엇을 보관하고 있을 까요?

바로, 특정한 데이터가 저장된 주소값을 보관하는 변수 입니다. 여기서 강조할 부분은 '주소값' 이라는 것 이지요. 여기서 그냥 머리에 박아 넣어 버립시다. 이전에 다른 책들에서 배운 내용을 싹 다 잊어 버리고 그냥 망치로 때려 넣듯이 박아버려요. 포인터에는 특정한 데이터가 저장된 주소값을 보관하는 변수 라고 말이지요. 크게 외치세요. '주소값!!!!!'

암튼, 뇌가 완전히 세뇌되었다고 생각하면 다음 단계로 넘어가도록 하겠습니다. 아직도 이상한 잡념이 머리에 남아 있다면 크게 숨을 호흡하시고 주소값이라고 10 번만 외쳐 보세요..

자. 되었습니다. 이제 포인터의 세계로 출발해 봅시다. 뿅

포인터

다시 한 번 정리하자면

포인터 : 메모리 상에 위치한 특정한 데이터의 (시작)주소값을 보관하는 변수

우리가 변수를 정의할 때 intchar 처럼 여러가지 형(type) 들이 있었습니다. 그런데 놀랍게도 포인터에서도 형이 있습니다.

이 말은 포인터가 메모리 상의 int 형 데이타의 주소값을 저장하는 포인터와, char 형 데이터의 주소값을 저장하는 포인터가 서로 다르다는 말입니다. 응?? 여러분의 머리속에는 아래와 같은 생각이 번개 처럼 스쳐 지나갈 것입니다.

아까 포인터는 주소값을 저장하는 거래며. 근데 우리가 쓰는 컴퓨터에선 주소값이 무조건 32 비트, 즉 4 바이트래며! 그러면 포인터의 크기는 다 똑같은것 아냐? 근데 왜 포인터가 형(type)을 가지는 건데?!

휴우우. 진정좀 하시고. 여러분 말이 백번 맞습니다 - 단, 현재 까지 배운 내용을 가지고 생각하자면 말이지요. 포인터를 아주 조금만 배우면 왜 포인터에 형(type) 이 필요한지 알게 될 것입니다.

C 언어에서 포인터는 다음과 같이 정의할 수 있습니다.

(포인터에 주소값이 저장되는 데이터의 형) *(포인터의 이름);

혹은 아래와 같이 정의할 수 도 있습니다.

(포인터에 주소값이 저장되는 데이터의 형)* (포인터의 이름);

예를 들어 p 라는 포인터가 int 데이터를 가리키고 싶다고 하면

int* p;  // 라고 하거나
int* p;  // 로 하면 된다

라 하면 올바르게 됩니다. 즉 위 포인터 pint 형 데이터의 주소값을 저장하는 변수가 되는 것 입니다. 와우!

& 연산자

그런데 말입니다. 아직도 2% 부족합니다. 포인터를 정의하였으면 값을 집어 넣어야 하는데, 도대체 우리가 데이터의 주소값을 어떻게 아냐는 말입니까? 걱정 마십시요. 바로 & 연산자를 사용하면 됩니다.

그런데, 아마 복습을 철저하게 잘하신 분들은 당황할 수 도 있습니다. 왜냐하면 &AND 연산자이기 때문입니다. (4 강 참조) 그런데, & 연산자를 사용하기 위해서는 두 개의 피연산자를 사용해야 합니다. 즉,

a& b;  // 괜찮음
a&     // 오류

와 같이 언제나 2 개가 필요 하다는 것이지요. 그런데, 여기에서 소개할 & 연산자는 오직 피연산자가 1 개인 연산자 입니다. (이러한 연산자를 단항(unary) 연산자라 합니다) 따라서 위의 AND 연산자와 완전히 다르게 해석됩니다.

단항 & 연산자는 피연산자의 주소값을 불러 옵니다. 사용하는 방법은 그냥

&/* 주소값을 계산할 데이터 */

예를 들어서 어떤 변수 a 의 주소값을 알고 싶다면

&a

로 쓰면 됩니다!

백설(說)이 불여일행(行). 한 번 프로그램을 짜 봅시다.

/* & 연산자 */
#include <stdio.h>
int main() {
  int a;
  a = 2;

  printf("%p \n", &a);
  return 0;
}

성공적으로 컴파일 했다면

실행 결과

0x7fff80505b64 

와 같이 나옵니다. 참고로, 여러분의 컴퓨터에 따라 결과가 다르게 나올 수 도 있습니다. 사실, 저와 정말 인연 이상의 무언가가 있지 않는 이상 전혀 다르게 나올 것 입니다. 더 놀라운 것은 실행할 때 마다 결과가 달라질 것입니다.

2 번째 실행한 것

실행 결과

0x7ffe37d03104 

위와 같이 나오는 이유는 나중에 설명하겠지만 주목할 것은 어떠한 이 출력되었다는 것 입니다.

printf("%x \n", &a);

위 문장에서 &a 의 값을 16 진수 형태 (%p) 로 출력하라고 명령하였습니다. 근데요. 눈치가 있는 사람이라면 금방 알겠지만 위에서 출력된 결과는 8 바이트 (16 진수로 16 자리)가 아닙니다! (여러분의 컴퓨터는 다를 수 있습니다.) 제가 지금 64 비트 운영체제를 사용하고 있는데도 말이지요!

그렇다면 뭐가 문제인가요? 사실, 문제는 없습니다. 단순히 앞의 0 이 잘린 것 이지요. 주소값은 언제나 8 바이트 크기, 즉 16 진수로 16 자리 인데 앞에 0 이 잘려서 출력이 안된 것일 뿐입니다. 따라서 변수 a 의 주소는 아마도 0x00007ffe37d03104 가 될 것입니다.

아무튼 위 결과를 보면, 적어도 제 컴퓨터 상에선 int 변수 a 는 메모리 상에서 0x7ffe37d03104 를 시작으로 4 바이트의 공간을 차지하고 있었다는 사실을 알 수 있습니다.

자, 이제 & 연산자를 사용하여 특정한 데이터의 메모리 상의 주소값을 알 수 있다는 사실을 알았으니 배고픈 포인터에게 값을 넣어 봅시다.

/* 포인터의 시작 */
#include <stdio.h>
int main() {
  int *p;
  int a;

  p = &a;

  printf("포인터 p 에 들어 있는 값 : %p \n", p);
  printf("int 변수 a 가 저장된 주소 : %p \n", &a);

  return 0;
}

실행해 보면 많은 이들이 예상했던 것 처럼....

실행 결과

포인터 p 에 들어 있는 값 : 0x7fff894c8b3c 
int 변수 a 가 저장된 주소 : 0x7fff894c8b3c 

똑같이 나옵니다. 어찌 보면 당연한 일입니다.

p = &a;

에서 포인터 pa 의 주소를 대입하였기 때문이죠. 참고로, 한 번 정의된 변수의 주소값은 바뀌지 않습니다. 따라서 아래 printf 에서 포인터 p 에 저장된 값과 변수 a 의 주소값이 동일하게 나오게 됩니다. 어때요. 쉽죠?

* 연산자

현재 까지 우리가 배운 바로는 포인터는 특정한 데이터의 주소값을 보관한다. 이 때 포인터는 주소값을 보관하는 데이터의 형에 * 를 붙임으로써 정의되고, & 연산자로 특정한 데이터의 메모리 상의 주소값을 알아올 수 있다 였습니다.

& 연산자가 어떠한 데이터의 주소값을 얻어내는 연산자라면 거꾸로 주소값에서 해당 주소값에 대응되는 데이터를 가져오는 연산자가 필요하겠지요? 이 역할은 바로 * 연산자가 수행합니다!

잠깐만. * 연산자는 이미 곱셈 연산자로 사용되고 있지 않나요? 맞습니다. 다만, * 연산자가 피연산자 두 개에 작용할 때만 곱셈 연산자로 해석됩니다. 즉,

a * b; // a 와 b 를 곱한다.
a *; // 오류!
*a; // 단항 * 연산자

와 같이 해석됩니다.

* 연산자의 역할을 쉽게 풀이하자면

"나(포인터)를 나에게 저장된 주소값에 위치한 데이터로 생각해줘!"

의 역할을 수행합니다. 한 번 아래 예제를 봅시다.

/* * 연산자의 이용 */
#include <stdio.h>
int main() {
  int *p;
  int a;

  p = &a;
  a = 2;

  printf("a 의 값 : %d \n", a);
  printf("*p 의 값 : %d \n", *p);

  return 0;
}

성공적으로 컴파일 한다면

실행 결과

a 의 값 : 2 
*p 의 값 : 2 

가 됩니다.

int *p;
int a;

일단 int 데이터를 가리키는 포인터 pint 변수 a 를 각각 정의하였습니다. 평범한 문장 이지요.

p = &a;
a = 2;

그리고 포인터 pa 의 주소를 집어 넣었습니다. 그리고 a 에 2 를 대입하였습니다.

printf("a 의 값 : %d \n", a);
printf("*p 의 값 : %d \n", *p);

일단 위의 문장은 단순 합니다. a 의 값을 출력하란 말이지요. 당연하게도 2 가 출력됩니다. 그런데, 아래에서 *p 의 값을 출력하라고 했습니다. * 의 의미는 앞서, 나에 저장된 주소값에 해당하는 데이터로 생각하시오! 로 하게 하는 연산자라고 하였습니다.

따라서 *p 를 통해 p 에 저장된 주소(변수 a 의 주소)에 해당하는 데이터, 즉 변수 a 그 자체를 의미할 수 있게 됩니다.

다시 말해 *p 와 변수 a 는 정확히 동일합니다. 즉, 위 두 문장은 아래 두 문장과 백프로 일치합니다.

printf("a 의 값 : %d \n", a);
printf("*p 의 값 : %d \n", a);

마지막으로 * 와 관련된 예제 하나를 더 살펴 봅시다.

/* * 연산자 */
#include <stdio.h>
int main() {
  int *p;
  int a;

  p = &a;
  *p = 3;

  printf("a 의 값 : %d \n", a);
  printf("*p 의 값 : %d \n", *p);

  return 0;
}

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

실행 결과

a 의 값 : 3 
*p 의 값 : 3 

아마 많은 여러분들이 예상했던 결과 이길 바랍니다!

p = &a;
*p = 3;

위에서도 마찬가지로 p 에 변수 a 의 주소를 집어 넣었습니다. 그리고 *p 를 통해 "나에 저장된 주소(변수 a 의 주소)에 해당하는 데이터(변수 a) 로 생각하시오" 를 의미하여 *p = 3a = 3 과 동일한 의미를 지니게 되었습니다. 어때요. 간단하지요? 이로써 여러분은 포인터의 50% 이상을 이해하신 것 입니다~! 짝짝짝짝

자. 그럼 포인터 라는 말 자체의 의미를 생각해 봅시다. int 변수 a 와 포인터 p 의 메모리 상의 모습을 그리면 아래와 같습니다.

포인터 p 도, a 도 메모리 상에 각각 존재합니다만, 그 위치에는 a 의 경우 3 이 있지만 p 의 경우 a 의 주소값이 있습니다.

참고로 주소값은 제가 임의로 정한 것 입니다.

포인터 p 에 어떤 변수 a 의 주소값이 저장되어 있다면 포인터 p 는 변수 a 를 가리킨다 라고 말합니다. 포인터 또한 엄연한 변수 이기 때문에 특정한 메모리 공간을 차지합니다. 따라서 위 그림과 같이 포인터도 자기 자신만의 주소를 가지고 있습니다.

포인터에는 왜 타입이 있을까

여기 까지 왔다면 아마 다음과 같은 의문을 가질 수 있을 것입니다.

포인터가 주소값만 보관하는데 왜 굳이 타입이 필요할까? 어차피 주소값은 32 비트 시스템에서 항상 4 바이트이고, 64 비트 시스템에서는 8 바이트 인데 그냥 pointer 라는 타입을 만들어버리면 안될까?

아주 좋은 질문 입니다. pointer 라는 타입이 있다고 생각해고 아래의 코드를 살펴봅시다.

int a;
pointer *p;
p = &a;
*p = 4;

컴퓨터 입장에서 위 코드를 어떤 식으로 해석할 지 생각해볼까요.

int a;
pointer *p;
p = &a;

위 세 문장 까지는 아주 좋습니다. 메모리에 a 를 위해서 4 바이트 짜리 공간을 마련해줬고, 마찬가지로 p 를 위해 메모리 상에 8 바이트 짜리 공간을 마련하였습니다. 그리고 pa 의 주소값을 잘 전달하였지요.

문제는 아래 문장 입니다.

*p = 4;

포인터 p 에는 명백히 변수 a 의 주소값이 들어 있습니다. 여기서 문제는 a 가 메모리에서 차지하는 모든 주소들의 위치가 들어 있는 것이 아니라 시작 주소 만 들어가 있다는 점입니다.

따라서, *p 라고 했을 때 컴퓨터는 메모리에서 얼마만큼을 읽어들어야 할지 알 길이 없습니다.

한편

int a;
int *p;
p = &a;
*p = 4;

라고 한다면 어떨 까요? 컴퓨터는 포인터 pint * 라는 사실을 보고 이 포인터는 int 데이터를 가리키는 구나! 라고 알게 되어 시작 주소로 부터 정확히 4 바이트를 읽어 들어 값을 바꾸게 됩니다.

포인터도 변수다

/* 포인터도 변수이다 */
#include <stdio.h>
int main() {
  int a;
  int b;
  int *p;

  p = &a;
  *p = 2;
  p = &b;
  *p = 4;

  printf("a : %d \n", a);
  printf("b : %d \n", b);
  return 0;
}

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

실행 결과

a : 2 
b : 4 
p = &a;
*p = 2;
p = &b;
*p = 4;

사실, 이런 예제까지 굳이 보여주어야 하나 하는 생각이 들었지만 그래도 혹시나 하는 마음에 했습니다. 앞에서도 말했듯이 포인터는 변수 입니다.

즉, 포인터에 들어간 주소값이 바뀔 수 있다는 것이지요. 위와 같이 처음에 a 를 가리켰다가, (즉 p 에 변수 a 의 주소값이 들어갔다가) 나중에 b 를 가리킬 수 (즉 p 에 변수 b 의 주소값이 들어감) 있다는 것 이지요. 뭐 특별히 중요한 예제는 아니였습니다만. 나중에 상수 포인터, 포인터 상수에 대해 이야기 하면서 다시 다루어 보도록 하겠습니다.

마지막으로, 강의를 마치며 여러분에게 포인터에 대해 완벽히 뇌리에 꽂힐 만한 동화를 들려드리겠습니다.

옛날 옛날에 대략 2 년 전에 (뭐.. 전 여러분과 옛날의 정의가 다릅니다ㅋ) 변철수, 변수철, 포영희라는 세 명의 사람이 OO 아파트에 살고 있었습니다.

int chul, sue;
int *young;

그런데 말이죠. 포영희는 변철수를 너무나 좋아한 나머지 자기 집 대문 앞에 큰 글씨로 "우리집에 오는 것들은 모두 철수네 주세요" 라고 써 놓고 철수네 주소를 적어 놓았습니다

young = &chul;

어느날 택배 아저씨가 영희네 집에 물건을 배달하러 왔다가 영희의 메세지를 보고 철수네에 가져다 주게 됩니다.

*young = 3; // 사실 chul = 3 과 동일하다!

영희에 짝사랑이 계속 되다가 어느날 영희는 철수 보다 더 미남인 수철이를 보게 됩니다. 결국 영희는 마음이 변심하고 수철이를 좋아하기로 했죠. 영희는 자기 대문 앞에 있던 메세지를 떼 버리고 "우리집에 오는 것은 모두 수철이네 주세요." 라 쓰고 수철이네 주소를 적었습니다.

young = &sue;

며칠이 지나 택배 아저씨는 물건을 배달하러 영희네에 왔다가 메세지를 보고 이번엔 수철이네에 가져다 줍니다.

*young = 4; // 사실 sue = 4 와 동일하다

이렇게 순수한 사랑이 OO 아파트에서 모락 모락 피어났습니다..... 끝

return 0; // 종료를 나타내는 것인데, 아직 몰라도 되요. (정확히 말하면 리턴...)

생각해 볼 문제

문제 1

*& 연산자의 역할이 무엇인지 말해보세요 (난이도 : 下)

문제 2

int **a; 와 같은 이중 포인터(double-pointer) 에 대해 생각해 보세요 (난이도 : 中上)

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

현재 여러분이 보신 강좌는 <씹어먹는 C 언어 - <12 - 1. 포인터는 영희이다! (포인터)>> 입니다. 이번 강좌의 모든 예제들의 코드를 보지 않고 짤 수준까지 강좌를 읽어 보시기 전까지 다음 강좌로 넘어가지 말아주세요
댓글이 174 개 있습니다!
프로필 사진 없음
강좌에 관련 없이 궁금한 내용은 여기를 사용해주세요

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