포인터란 무엇인가
C/C++ 의 장벽 포인터. 너는 대체 무엇이냐?
글에 들어가기에 앞서, 이 글을 요약하면 다음과 같습니다.
- "포인터는 메모리의 주소를 담는 변수이다"
- 포인터의 자료형은 **“해당 메모리 주소부터 어디까지를 하나의 자료로 볼 것인가”**를 결정한다.
* 연산자
는 해당 포인터가 가리키고 있는 자료의 값을 내뱉어준다.
해당 글은 포인터를 사용하는 방법보다는, 포인터가 무엇을 저장하고 어떻게 작동하는 지에 대해서 중점적으로 설명하는 글입니다. 때문에 C에 대한 기초적인 지식과 일부 컴퓨터의 작동원리에 대한 이해가 필요합니다.
포인터의 개념
포인터도 변수다
포인터를 이해하기에 앞서, 포인터가 담고 있는 값에 대해서 이해를 해야한다. 사실 이를 이해하면 포인터를 모두 이해한 것이나 다름없다. 정말 간단하게 한 줄로 표현할 수 있다.
"포인터는 메모리의 주소를 담는 변수이다"
포인터는 단순히 메모리의 주소를 담는 변수이다. int *
int **
심지어 vector<int> ******
도 마찬가지이다. 포인터 변수 속에는 결국 어떤 메모리의 주소가 담겨있다. 심지어, 메모리 주소값은 크기가 동일하기 때문에 vector<int> ******
와 int *
void *
의 자료형 크기도 동일하다.
/* 포인터 자료형의 크기는 얼마일까? */
#include <stdio.h>
int main(void)
{
printf("sizeof int * %ld\n", sizeof(int *));
printf("sizeof void * %ld\n", sizeof(void *));
printf("sizeof char * %ld\n", sizeof(char *));
printf("sizeof int ****** %ld\n", sizeof(int ******));
return (0);
}
sizeof int * 8
sizeof void * 8
sizeof char * 8
sizeof int ****** 8
이제 포인터가 무슨 값을 담는지 이해했다. 그런데 다시 또 다른 난관에 부딪힌다. '그러면 타입은 왜 다른건데?'
포인터 자료형
포인터 변수도 여러가지 타입이 있다. 단순히 메모리 주소를 저장하는 변수라면서 왜 자료형을 명시하는걸까? 이를 이해하기 위해서는 자료형을 붙인 포인터들이 어떤 행동을 할 수 있는지 이해해야한다. 아래 코드를 보면서 이해해보자.
모든 포인터는 모든 메모리 주소를 담을 수 있다.
- 우선, 다른 자료형의 포인터에 주소값을 넣는 경우 어떤 값을 담고있는지 보자. 명시된 자료형이 다를 때, 메모리 주소가 아닌 특별한 것을 저장할까? 그렇다면 원래 자료의 주소와 다른 값이 저장되어있을 것이다.
#include <stdio.h>
int main(void)
{
char charValue = 'a';
int intValue = 1;
long long longValue = 2;
char *charPValue = &intValue;
int *intPValue = &charValue;
long long *longPValue = &charPValue;
char **charPPValue = &longValue;
printf("charP is %X original address is %X\n", charPValue, &intValue);
printf("intP is %X original address is %X\n", intPValue, &charValue);
printf("longP is %X original address is %X\n", longPValue, &charPValue);
printf("charPP is %X original address is %X\n", charPPValue, &longValue);
return (0);
}
charP is 6EE6F564 original address is 6EE6F564
intP is 6EE6F56B original address is 6EE6F56B
longP is 6EE6F550 original address is 6EE6F550
charPP is 6EE6F558 original address is 6EE6F558
- 우리가 앞서 배운대로, 원 자료형과 관계없이 해당 자료의 주소가 담겨있다.
- 그렇다면, 포인터의 자료형은 어떤 역할을 하는 것일까?
포인터 자료형의 역할
-
포인터의 자료형은 **“해당 메모리 주소부터 어디까지를 하나의 자료로 볼 것인가”**를 결정한다.
int
라면4byte
를 하나의 자료로,char
라면1byte
를 하나의 자료로 인식한다. -
이를 응용한 것이 포인터의 증감연산이다.
int arr[4] = {1,2,3,4}; // arr에는 연속적으로 1, 2, 3, 4 가 저장되어있을 것이다. int *ptr = &arr[0]; // arr의 첫번째 값인 arr[0]의 주소를 포인터로 저장해보자. ptr = ptr + 1; // 이는 &arr[0 + 1] 과 같은 결과를 가져온다.
위 상황에서
ptr
값은 다음과 같이 변화한다.arr[0]
의 주소값을0x10
이라고 가정해보자.*ptr = &arr[0]
→ptr
에는0x10
이라는 값이 저장된다.ptr = ptr + 1
→ptr
은int
자료형을 가리키는 포인터이다. 따라서,4byte
만큼의 값이 증가한다. 결과적으로0x14
라는 값이 저장될 것이고 이는arr[1]
의 주소와 같다.- 해당 부분에서 많은 이들이
포인터 == 배열
라는 오해를 안고 코드를 작성한다. 하지만 다시 명심하자. 배열과 포인터는 엄연히 다르다. 포인터는 단순히 메모리의 주소를 담는다. 증감 연산을 통해 연속적인 메모리에 할당된 자료에서 그 다음 주소를 가져올 수 있는 것 뿐이다. 배열에 있는 자료의 메모리 주소를 가리키는 것이지 연속적인 메모리 할당은 관련 없다.
-
하나만 기억하자
“포인터의 자료형은 **“해당 메모리 주소부터 어디까지를 하나의 자료로 볼 것인가”**를 결정한다”
-
예시 코드를 하나 더 살펴보자.
#include <iostream>
#include <bitset>
int main(void)
{
unsigned long long val = 0xffffffff11111111; // 8bytes
void *tmp = &val;
unsigned int *ptr = (unsigned int *)tmp;
std::cout << "\nval의 데이터는 어떻게 될까?\n";
std::cout << std::bitset<64>(val).to_string();
std::cout << "\nptr 이 바라보고 있는 값은 뭘까?\n";
std::cout << std::bitset<32>(*ptr).to_string() << '\n';
std::cout << std::bitset<32>(*++ptr).to_string();
}
💡 big endian - little endian 개념을 유의하며 결과를 이해하자!
val의 데이터는 어떻게 될까?
1111111111111111111111111111111100010001000100010001000100010001
ptr 이 바라보고 있는 값은 뭘까?
00010001000100010001000100010001
11111111111111111111111111111111
- 결과를 보면, 8바이트의 데이터 중 앞선 4바이트의 데이터를 포인터가 바라보고 있는 것을 알 수 있다.
- 이후 그 다음의 데이터를 볼 때,
long long
의 영역 중 그 다음4byte
부분을 바라본다. - 이처럼, 포인터는 해당 포인터가 가지고 있는 메모리 주소부터 자료형의 크기만큼을 데이터로 바라본다.
추가 예시로 int type
에 저장된 값을 char *
로 바라본 예시를 생각해보자.
#include <stdio.h>
int main(void)
{
int a = 0x01020304;
unsigned char *ptr = (unsigned char *)&a;
for (int i = 0; i < 4; ++i)
printf("%d\n", *(ptr++));
}
4
3
2
1
*연산자
- 포인터에는 특별한 연산자가 있다. 바로
* 연산자
이다.* 연산자
는 해당 포인터가 가리키고 있는 자료의 값을 내뱉어준다. - 앞서서, 포인터의 자료형은 해당 포인터가 가지고 있는 메모리 주소부터 어디까지를 자료로 인식할 지 결정해준다는 것을 알았다.
* 연산자
를 활용하면 해당 메모리 주소부터 자료형의 크기 만큼을 해당 자료형의 자료로 보고 해당 값을 얻을 수 있다.
다중 포인터도 어렵지 않아요
- 포인터가 하나일 때는 쉽게 쓰지만,
*
가 점점 늘어갈수록 당황하는 경우가 많다. 그러나 당황할 필요가 전혀 없다. 쉽게 이렇게 이해해보자. - 포인터는
자료형 *
의 형태이다. 따라서, 여러개의*
가 붙더라도 마지막*
만 떼어 이해해보자 int **
는 결국 다음과 같은 포인터이다.int*
자료형을 가리키는 포인터 (포인터 자료형에 대해서 가리키고 있으니,8byte
만큼을 바라봄)
- 마찬가지로
int *****
는 다음과 같다.int****
자료형을 가리키는 포인터 (포인터 자료형에 대해서 가리키고 있으니,8byte
만큼을 바라봄)
- 그리고 다중 포인터도 마찬가지로 절대 2차원, 3차원, 4차원… 배열이 아니다! 그저 어떤 포인터 자료형을 가리키는 포인터일 뿐이다.
포인터의 활용
이제 포인터의 기본 개념에 대해서는 모두 짚어보았다. 포인터를 활용하는 대표적인 예시를 간단히 살펴보자.
Heap 영역에 대한 접근
- 일반적으로 데이터는
Stack
영역에 생성된다. 그러나, 프로그램의 동작 중에 동적으로 메모리를 할당하여 데이터를 만들기 위해서는Heap
영역을 활용한다. - 이 때,
Heap
영역의 데이터를 접근하고, 제어하기 위한 수단으로 포인터를 활용한다.malloc
을 통해 메모리를 할당할 때, 메모리의 주소를 넘겨주는 것도 이 이유 때문이다.
Call by reference 구현
- 함수 내에서 함수 외부의 데이터를 접근하고, 제어하고 싶을 경우가 있다. 이 때, 매개변수로 포인터를 사용한다. 함수 외부의 데이터의 주소값을 포인터를 통해 전달함으로써 외부 데이터를 조작할 수 있다.
다양한 자료 구조 구현
- 자료구조 구현에서 포인터는 정말 중요하다.
Linked List
,Tree
,Graph
등 다양한 자료 구조에서 노드간의 연결을 구현하기 위해 포인터를 활용한다. 포인터를 통해 동적이고 효율적인 자료구조 구현이 가능해진다.
배열에 대한 접근
- 연속된 메모리가 연속된 데이터로 저장되는 배열에는 포인터가 유용하다. 배열의 첫 번째 주소값을 포인터로 전달하여 해당 배열을 접근할 수 있게끔 하며, 증감 연산을 통해 그 다음 데이터에도 손쉽게 접근할 수 있다.
글을 마치며
“포인터는 어렵지 않아요" 라고 생각하며 최대한 간단하게 포인터를 이해할 수 있는 글을 작성해보고자 했지만, 막상 글을 적고 보니 이해하기 어려울 만하다는 생각이 듭니다. 포인터를 이해하기 위한 핵심 개념들을 이해하기 위해서는 컴퓨터가 실제로 어떻게 작동하는 지를 이해해야합니다. 때문에 접근하기가 어렵고 많은 분들이 어려움을 느끼는 것 같다는 것을 이번 글을 작성하며 느끼게 되었습니다.
하지만, 그 만큼 포인터를 이해하는 것은 중요합니다. 포인터를 통해 실제로 컴퓨터가 어떻게 변수를 취급하며, 접근하고 변경하는 지를 이해할 수 있습니다. 그래서 응용보다는 실제로 포인터가 어떤 값을 담고, 어떻게 작동하는 지에 대해서 중점적으로 다뤘습니다.
마지막으로, 포인터와 자료형의 원리를 통달하여 기가막힌 알고리즘을 개발한 영상으로 마무리해봅니다.