주소를 담는 변수
모든 변수는 메모리 어딘가에, 주소라고 불리는 번호가 매겨진 위치에 존재합니다. 대부분의 경우 그 위치가 어디인지는 신경 쓰지 않고 변수 이름만 사용합니다. 포인터는 이를 뒤집습니다. 값 자체가 주소인 변수입니다. 42 를 담는 대신 "42 가 저장된 곳"을 담습니다.
이 간접 참조가 바로 포인터를 강력하게 만듭니다. 함수는 포인터를 통해 호출자의 변수를 바꿀 수 있고, 연결 리스트 같은 자료 구조는 포인터로 노드들을 이어 붙이며, (동적 메모리에서 보게 되듯이) 런타임에 할당한 메모리에 도달하는 방법도 포인터입니다.
&score 의 & 는 주소 연산자로, score 의 위치를 만들어 냅니다. *p 의 * 는 역참조 연산자로, 주소를 따라가 그곳에 존재하는 값으로 되돌아갑니다.
두 연산자: & 와 *
초보자에게 가장 헷갈리는 점은 * 가 놓이는 위치에 따라 두 가지 다른 의미를 가진다는 것입니다. 이것을 분명히 구분하세요.
int* p; // 선언: "p 는 int 를 가리키는 포인터"
p = &x; // & = 주소 연산자: x 의 주소를 p 에 저장
int y = *p; // * = 역참조: p 가 가리키는 값을 읽음
*p = 99; // 왼쪽에서의 역참조: 포인터를 통해 씀
선언에서 * 는 타입의 일부입니다. 식에서 * 는 실제 작업을 합니다. 포인터가 일단 설정되면, 그것을 역참조하면 원래 변수에 대한 완전한 읽기/쓰기 접근이 가능합니다.
1행 이후로 health 를 이름으로 한 번도 건드리지 않았는데도 그 값이 계속 바뀌었다는 점에 주목하세요. 바로 그것이 핵심입니다. hp 는 같은 저장 공간에 대한 별칭입니다. 공백 처리(int* p, int *p, int*p)는 외형상의 차이일 뿐 컴파일러에게는 동일하며, 이 가이드는 int* p 를 사용합니다.
nullptr: 아무것도 가리키지 않기
아무 곳도 가리키지 않는 포인터는 nullptr(C++11)로 설정해야 합니다. "아직 대상이 없음"을 분명하고 타입 안전하게 말하는 방법이며, 역참조 전에 검사할 대상을 제공합니다.
오래된 NULL 매크로나 맨 0 보다 nullptr 를 선호하세요. nullptr 는 진짜 포인터 타입을 가지므로 오버로드 해석 시 정수 0 으로 잘못 읽히는 일이 결코 없습니다. 이는 옛 스타일이 일으킬 수 있던 미묘한 버그입니다.
함정 - 널 역참조. 널(또는 초기화되지 않은) 포인터를 통해 읽거나 쓰는 것은 미정의 동작이며, 보통 즉각적인 크래시입니다.
int* p = nullptr;
cout << *p; // 크래시 - null 역참조는 미정의 동작
null 일 수 있는 무언가를 역참조하기 전에는 항상 if (p)(또는 if (p != nullptr))로 방어하세요.
포인터와 배열
배열 이름은 첫 번째 원소에 대한 포인터로 붕괴(decay)하므로 포인터와 배열은 깊이 얽혀 있습니다. 포인터에 1 을 더하면 1바이트가 아니라 1원소만큼 전진하며, 이것이 포인터 산술을 작동하게 합니다.
p[i] 와 *(p + i) 는 문자 그대로 같은 식입니다. 이 동등성이 바로 배열이 0부터 인덱싱되는 이유입니다. 여기서 전형적인 버그는 끝을 지나치는 것입니다. nums + 4 는 비교에 쓰기 좋은 유효한 "끝 바로 다음" 표지이지만, *(nums + 4) 를 역참조하면 범위를 벗어나 읽습니다. 포인터의 off-by-one(하나 차이) 오류는 크래시와 조용한 손상의 주요 원인이므로, 정지 조건을 신중하게 작성하세요.
const 와 포인터
const 는 포인터가 가리키는 대상에, 포인터 자체에, 또는 둘 다에 적용될 수 있습니다. 해독하려면 선언을 오른쪽에서 왼쪽으로 읽으세요.
const int* p; // const int 를 가리키는 포인터 - *p 변경 불가, p 재지정 가능
int* const p = &x; // int 를 가리키는 const 포인터 - *p 변경 가능, p 재지정 불가
const int* const p = &x; // 둘 다 잠김
이것은 실제 코드에서 끊임없이 중요합니다. 데이터를 수정하지 않겠다고 약속하는 함수는 const 에 대한 포인터를 받습니다.
가리키는 대상을 const 로 표시하는 것은 의도를 문서화하고, 컴파일러가 실수에 의한 쓰기를 막게 해 줍니다. 런타임 비용이 전혀 없는 공짜 안전성입니다.
큰 함정: 댕글링 포인터
댕글링 포인터는 더 이상 기대한 값을 담고 있지 않은 메모리를 가리킵니다. 변수가 스코프를 벗어났거나 메모리가 해제된 것입니다. 그것을 역참조하는 것은 미정의 동작이며, 고약한 점은 망가지기 전까지는 종종 작동하는 것처럼 보인다는 것입니다.
int* makeBad() {
int local = 5;
return &local; // 버그: 함수가 반환하면 local 은 소멸한다
} // 반환된 포인터는 이제 댕글링 상태
주소는 여전히 유효한 숫자이지만, 회수된 스택 슬롯을 가리킵니다. 그것을 읽으면 쓰레기 값이 나오거나 크래시가 납니다. delete 된 힙 객체나, 나중에 재할당하는 vector 의 원소에 대한 포인터를 계속 가지고 있어도 같은 일이 일어납니다.
세 가지 규칙이 여러분을 안전하게 지켜 줍니다.
- 지역 변수의 주소를 절대 반환하지 마세요. 값으로 반환하거나, 호출자가 저장 공간을 소유하게 하세요.
- 포인터가 가리키던 것이 사라진 뒤에는 포인터를
nullptr로 설정하고, 사용하기 전에 확인하세요. - 소유권과 수명 관리에는 맨
new/delete대신 스마트 포인터를 사용하세요. 메모리를 자동으로 해제하고 이 부류의 버그 전체를 줄여 줍니다.
다음: 참조 대 포인터
다른 변수를 간접적으로 가리키는 방법은 포인터만이 아닙니다. C++에는 참조도 있는데, 비슷하게 느껴지지만 null 이 될 수 없고, 다시 묶을 수 없으며, 더 깔끔한 문법을 사용합니다. 다음에는 참조 대 포인터에서 둘을 나란히 놓고 비교하여, 어떤 도구를 골라야 할지 — 그리고 현대 C++이 가능하면 왜 대부분 참조를 선호하는지 — 정확히 알 수 있게 하겠습니다.
자주 묻는 질문
C++에서 포인터란 무엇인가요?
포인터는 어떤 값 자체가 아니라 그 값의 메모리 주소를 저장하는 변수입니다. * 로 선언하고(예: int* p), & 연산자로 주소를 얻으며(p = &x), *p 로 역참조하여 가리키는 값을 읽거나 씁니다.
C++ 포인터에서 & 와 * 의 차이는 무엇인가요?
포인터 문맥에서 & 는 주소 연산자로, &x 는 x 의 주소를 줍니다. * 는 두 가지 역할을 합니다. 선언(int* p)에서는 변수를 포인터로 표시하고, 식(*p)에서는 포인터를 역참조하여 그 주소에 저장된 값에 도달합니다.
C++의 nullptr란 무엇이며 왜 NULL 대신 사용하나요?
nullptr 는 C++11에서 추가된 타입 안전한 널 포인터 리터럴입니다. "아무것도 가리키지 않음"을 뜻합니다. 오래된 NULL 이나 맨 0 보다 이것을 선호하세요. nullptr 는 진짜 포인터 타입이라서 오버로드 해석 시 절대 정수로 오인되지 않기 때문입니다. 역참조하기 전에는 항상 if (p) 로 확인하세요. 널 포인터를 역참조하는 것은 미정의 동작입니다.