포인터를 알기 전에 먼저 주소 값에 대해 이해해야 한다. 컴퓨터에서 메모리는 1 byte 단위로 주소가 매겨져 있으며 주소 값이란 해당 데이터가 저장된 메모리의 시작주소 1 byte 를 의미한다.
int 형 데이터는 4 byte 의 크기를 가지지만, int 형 데이터의 주소 값은 시작 주소 1 byte 만을 가리키게 된다.

포인터(Pointer)는 메모리의 주소 값을 가지고 있는 변수로 다음과 같이 선언 및 초기화한다.
자료형타입* 포인터이름;
자료형타입* 포인터이름 = &변수이;
int, float, double* 를 작성한다.#include <iostream>
int main(void)
{
int i = 10; // 정수형 변수 i 선언 및 10으로 초기화
int* p = &i; // 정수형 포인터 p 선언 및 변수 i의 주소로 초기화
std::cout << " i : " << i << std::endl;
std::cout << " p : " << p << std::endl;
std::cout << "&i : " << &i << std::endl;
return 0;
}
i : 10
p : 000000F5A0EFF934
&i : 000000F5A0EFF934
만약 포인터를 선언만 해놓고 이후 유효한 주소 값을 넣을 생각이라면 다음과 같이 nullptr 로 초기화하는 것이 좋다. nullptr 은 아무것도 가리키지 않는 것을 의미한다.
int* p = nullptr;
포인터의 크기는 운영체제의 비트 수에 따라 달라진다.
포인터 또한 참조 연산자 * 를 통해 참조할 수 있다.
#include <iostream>
int main(void)
{
int i = 10; // 정수형 변수 i 선언 및 10으로 초기화
int* p = &i; // 정수형 포인터 p 선언 및 변수 i의 주소로 초기화
std::cout << " i : " << i << std::endl;
std::cout << " p : " << p << std::endl;
std::cout << "*p : " << *p << std::endl;
return 0;
}
i : 10
p : 000000CC2E15FA24
*p : 10

이처럼 포인터는 개발자가 메모리를 직접 다룰 수 있다는 점에서 강력한 도구이지만 의도하지 않거나 잘못된 메모리의 접근으로 인해 오류를 발생시키는 원천이기도 하다. 따라서 포인터의 사용은 항상 주의해야 한다.
포인터는 주소 연산자 &, 참조 연산자 * 외에도 값을 증가시키거나 감소시키는 등의 제한된 연산을 할 수 있다. 포인터 연산에는 몇 가지 특징이 있따.
포인터 p 의 값이 0 이라고 가정하자.
일반적인 변수라면 1 을 더하면 1 이 될 것이다. 그러나 포인터 p 는 어떤 자료형을 가리키는 포인터인가에 따라 1 이 될 수도 있고 아닐 수도 있다. 만약 p 가 int 형 포인터였다면 p + 1 은 4 가 된다.
즉, 포인터와 정수 간의 덧셈, 뺄셈 시 변화하는 값은 포인터가 가리키는 데이터의 크기를 따른다. 위의 경우에서 p 가 char 형 포인터였다면 char 형의 크기는 1 byte 만큼 증가해 p + 1 은 1 이 되고, double 형 포인터였다면 double 형의 크기인 8 byte 만큼 증가해 p + 1 은 8 이 된다.
이는 증감 연산자 ++, -- 를 사용했을 경우에도 마찬가지이다.

#include <iostream>
int main(void)
{
// 초기 주소값 절대 주소 0 대입
char* p_char = 0; // 1byte 크기 char
int* p_int = 0; // 4byte 크기 int
double* p_double = 0; // 8byte 크기 double
std::cout << "원래 주소" << std::endl;
std::cout << " p_char : " << reinterpret_cast<void*>(p_char) << std::endl;
std::cout << " p_int : " << p_int << std::endl;
std::cout << "p_double : " << p_double << std::endl << std::endl;
std::cout << "연산후 주소" << std::endl;
std::cout << " p_char + 1 : " << reinterpret_cast<void*>(p_char + 1) << std::endl;
std::cout << " p_int + 1 : " << p_int + 1 << std::endl;
std::cout << " p_int + 2 : " << p_int + 2 << std::endl;
std::cout << "p_double + 1 : " << p_double + 1 << std::endl;
return 0;
}
원래 주소
p_char : 0000000000000000
p_int : 0000000000000000
p_double : 0000000000000000
연산후 주소
p_char + 1 : 0000000000000001
p_int + 1 : 0000000000000004
p_int + 2 : 0000000000000008
p_double + 1 : 0000000000000008
포인터가 가리키는 자료형 타입에 따라 결과가 다르게 나온다.
같은 자료형 타입의 포인터끼리의 대입, 비교 연산이 가능하다.
#include <iostream>
int main(void)
{
int num1 = 1;
int num2 = 2;
int* p1 = &num1;
int* p2 = &num2;
// 두 포인터 비교
if (p1 != p2)
{
std::cout << "두 포인터가 다른 주소를 가르킵니다." << std::endl;
std::cout << "p1 이 가르키는 주소: " << p1 << std::endl;
std::cout << "p2 가 가르키는 주소: " << p2 << std::endl;
std::cout << "p1 에 저장된 값 : " << *(p1) << std::endl;
std::cout << "p2 에 저장된 값 : " << *(p2) << std::endl << std::endl;
}
// 포인터 대입
p1 = p2;
// 두 포인터 비교
if (p1 == p2)
{
std::cout << "두 포인터가 같은 주소를 가르킵니다." << std::endl;
std::cout << "p1 이 가르키는 주소: " << p1 << std::endl;
std::cout << "p2 가 가르키는 주소: " << p2 << std::endl;
std::cout << "p1 에 저장된 값 : " << *(p1) << std::endl;
std::cout << "p2 에 저장된 값 : " << *(p2) << std::endl;
}
return 0;
}
두 포인터가 다른 주소를 가르킵니다.
p1 이 가르키는 주소: 00000094A1CFF794
p2 가 가르키는 주소: 00000094A1CFF7B4
p1 에 저장된 값 : 1
p2 에 저장된 값 : 2
두 포인터가 같은 주소를 가르킵니다.
p1 이 가르키는 주소: 00000094A1CFF7B4
p2 가 가르키는 주소: 00000094A1CFF7B4
p1 에 저장된 값 : 2
p2 에 저장된 값 : 2
같은 자료형 타입의 포인터끼리의 뺄셈은 두 포인터 간의 상대적인 거리를 의미한다.
예를 들어 int 형 포인터 p1 의 주소가 0 이고, int 형 포인터 p2 의 주소가 8 이라면 int 형의 크기 4 byte * 2 만큼 떨어져 있는 것이므로 p2 - p1 의 값은 2 이다.
#include <iostream>
int main(void)
{
// 4byte 크기의 int형
int* p_i1 = 0;
int* p_i2 = (int*)8; // 4byte * 2
// 8byte 크기의 double형
double* p_d1 = 0;
double* p_d2 = (double*)24; // 8byte * 3
std::cout << "p_i2 - p_i1 : " << p_i2 - p_i1 << std::endl;
std::cout << "p_d2 - p_d1 : " << p_d2 - p_d1 << std::endl;
return 0;
}
p_i2 - p_i1 : 2
p_d2 - p_d1 : 3
증감 연산자 ++, -- 은 간접 참조 연산자인 * 와 같이 사용될 수 있다.
여기서 주의해야 할 점은 증감 연산자를 포인터에 적용할 수도 있고, 포인터가 가리키는 대상에도 적용할 수 있다는 것이다.
| 수식 | 의미 |
|---|---|
v = *p++ |
p가 가리키는 값을 v에 대입한 후에 p를 증가시킨다. |
v == (*p)++ |
p가 가리키는 값을 v에 대입한 후에 p가 가리키는 값을 증가시킨다. |
v = *++p |
p를 증가시킨 후에 p가 가리키는 값을 v에 대입한다. |
v = ++*p |
p가 가리키는 값을 가져온 후에 그 값을 증가하여 v에 대입한다. |
포인터와 배열은 아주 밀접한 관계를 가지고 있다. 배열의 이름은 배열이 시작되는 주소를 가리킨다. 이를 이용해 배열을 포인터처럼 사용하거나 배열이 시작되는 주소를 포인터에 대입하여 포인터를 배열처럼 사용할 수 있다.
배열은 배열의 시작 주소를 가지고 있으며 참조 연산자 * 를 통해 배열 요소에 접근할 수 있다.
#include <iostream>
// 배열을 포인터처럼 사용
int main(void)
{
// 배열 생성
int arr[5] = { 1, 2, 3, 4, 5 };
// 배열 요소가 저장된 주소
std::cout << "배열 요소들의 주소" << std::endl;
for (int i = 0; i < 5; i++)
std::cout << arr + i << std::endl;
// 배열을 포인터처럼 사용해 배열 요소 값에 접근
std::cout << "\\n배열 요소값" << std::endl;
for (int i = 0; i < 5; i++)
std::cout << *(arr + i) << " ";
return 0;
}
배열 요소들의 주소
0000001583AFF698
0000001583AFF69C
0000001583AFF6A0
0000001583AFF6A4
0000001583AFF6A8
배열 요소값
1 2 3 4 5
배열 이름을 통해 배열을 포인터처럼 사용할 수 있는 것을 확인할 수 있다.
배열이 시작되는 주소를 포인터에 대입하여 포인터를 마치 배열처럼 사용할 수 있다.
#include <iostream>
// 포인터를 배열처럼 사용
int main(void)
{
// 배열 생성
int arr[5] = { 1, 2, 3, 4, 5 };
// 배열의 시작주소를 포인터에 대입
int* ptr = arr;
// 포인터를 배열처럼 사용가능
std::cout << "배열 요소값 (ptr)" << std::endl;
for (int i = 0; i < 5; i++)
std::cout << ptr[i] << " ";
std::cout << "\\n배열 요소값 (arr)" << std::endl;
for (int i = 0; i < 5; i++)
std::cout << arr[i] << " ";
// 배열 요소 접근 가능
ptr[0] = 10;
ptr[1] = 20;
ptr[2] = 30;
ptr[3] = 40;
ptr[4] = 50;
std::cout << "\\n\\n수정 후 배열 요소값 (ptr)" << std::endl;
for (int i = 0; i < 5; i++)
std::cout << ptr[i] << " ";
std::cout << "\\n수정 후 배열 요소값 (arr)" << std::endl;
for (int i = 0; i < 5; i++)
std::cout << arr[i] << " ";
return 0;
}
배열 요소값 (ptr)
1 2 3 4 5
배열 요소값 (arr)
1 2 3 4 5
수정 후 배열 요소값 (ptr)
10 20 30 40 50
수정 후 배열 요소값 (arr)
10 20 30 40 50
배열의 시작 주소를 포인터에 대입하여 포인터를 배열처럼 사용할 수 있다.
이와 같은 포인터와 배열 사이의 관계 때문에 C++에서 배열과 포인터가 서로 같거나 배열의 이름이 포인터 또는 포인터 상수라고 알고 있는 경우가 많다.
그러나 많은 경우에서 배열과 포인터간에 묵시적 형 변환이 일어나는 것이며 배열은 배열이고, 포인터는 포인터일 뿐 두 타입은 컴파일러에서 다르게 처리된다.
예를들어 sizeof 연산자에서 배열의 이름은 포인터로 변환되지 않는다. 만약 배열 이름이 포인터라면 sizeof 연산자를 사용할 경우 배열의 실제 크기가 아닌 포인터의 크기를 반환할 것이다. 그러나 포인터의 크기가 아닌 배열의 실제 크기를 반환한다.
#include <iostream>
int main()
{
int arr[5] = { 1, 2, 3, 4, 5 };
int* ptr = arr; // 포인터로 변환
std::cout << "sizeof(arr) : " << sizeof(arr) << std::endl;
std::cout << "sizeof(ptr) : " << sizeof(ptr) << std::endl;
return 0;
}
sizeof(arr) : 20
sizeof(ptr) : 8
sizeof 연산자의 피연산자로 배열 이름을 사용했을 때는 배열의 실제 크기를, 포인터로 변환해서 사용했을 때는 포인터의 크기를 반환하는 것을 확인할 수 있다.