포인터 기초
C++ 프로그래밍에서 가장 강력하면서도 동시에 가장 어렵다고 여겨지는 개념 중 하나인 포인터(Pointer) 에 대해 학습할 차례입니다.
포인터는 C++이 메모리를 직접 다룰 수 있게 해주는 핵심 도구이며, 이를 통해 프로그램의 성능을 최적화하거나 복잡한 자료 구조를 구현하는 등 다양한 고급 기능을 사용할 수 있습니다.
하지만 포인터는 강력한 만큼 오용 시 심각한 오류(예: 메모리 누수, 세그멘테이션 폴트)를 유발할 수 있으므로, 정확한 이해와 신중한 사용이 필수적입니다.
이 장에서는 포인터의 기본적인 개념과 선언, 그리고 핵심 연산자에 대해 알아보겠습니다.
메모리와 주소의 개념
컴퓨터의 메모리는 마치 거대한 아파트 단지처럼 구성되어 있습니다.
각 아파트 호실에는 고유한 주소(Address)가 있고, 그 주소에 어떤 데이터(세입자)가 살고 있습니다.
C++에서 변수를 선언하면, 이 변수는 메모리 어딘가에 공간을 할당받고 그 공간에 값이 저장됩니다. 이 공간의 위치를 나타내는 것이 바로 메모리 주소입니다.
int num = 10;
이라는 코드를 실행하면,num
이라는 변수가 메모리의 특정 주소(예:0x7ffee0000010
)에10
이라는 값을 저장합니다.
포인터는 이러한 메모리 주소를 값으로 저장하는 특별한 종류의 변수입니다.
일반 변수가 데이터(예: 숫자, 문자)를 저장하는 반면, 포인터 변수는 다른 변수의 주소(위치)를 저장합니다.
포인터 선언하기
포인터 변수를 선언할 때는 포인터가 가리킬 데이터의 타입과 *
(별표) 연산자를 사용합니다.
데이터타입* 포인터변수이름;
데이터타입
: 포인터가 가리킬 메모리 위치에 저장된 값의 타입입니다. (예:int*
는int
형 데이터를 가리키는 포인터,double*
는double
형 데이터를 가리키는 포인터).*
: 포인터 변수임을 나타내는 기호입니다. 변수 이름 앞에 붙입니다.
int* pNum; // int형 데이터를 가리킬 포인터 pNum 선언
double* pDouble; // double형 데이터를 가리킬 포인터 pDouble 선언
char* pChar; // char형 데이터를 가리킬 포인터 pChar 선언
포인터의 핵심 연산자
포인터를 효과적으로 사용하기 위해서는 두 가지 중요한 연산자를 알아야 합니다.
-
주소 연산자 (
&
, Address-of Operator)- 어떤 변수의 메모리 주소를 얻는 데 사용됩니다.
- 변수 이름 앞에
&
를 붙이면 해당 변수가 메모리에 저장된 시작 주소를 반환합니다.
-
역참조 연산자 (
*
, Dereference Operator / Indirection Operator)- 포인터가 가리키는 메모리 주소에 저장된 값에 접근하는 데 사용됩니다.
- 포인터 변수 이름 앞에
*
를 붙이면, 해당 포인터가 저장하고 있는 주소로 가서 그 주소에 있는 값을 가져옵니다.
#include <iostream>
int main() {
int num = 10; // 1. int형 변수 num 선언 및 초기화
int* pNum = nullptr; // 2. int형 포인터 pNum 선언 및 nullptr로 초기화 (아무것도 가리키지 않음)
std::cout << "변수 num의 값: " << num << std::endl; // 출력: 10
std::cout << "변수 num의 주소 (16진수): " << &num << std::endl; // num의 메모리 주소 (예: 0x7ffee0000010)
pNum = # // 3. pNum에 num 변수의 주소를 저장 (주소 연산자 & 사용)
std::cout << "\n포인터 pNum의 값 (num의 주소): " << pNum << std::endl; // pNum이 저장하고 있는 주소 (num의 주소와 동일)
std::cout << "포인터 pNum이 가리키는 값: " << *pNum << std::endl; // pNum이 가리키는 주소의 값 (역참조 연산자 * 사용) -> 10
// 포인터를 통해 값을 변경
*pNum = 20; // pNum이 가리키는 주소의 값을 20으로 변경
std::cout << "\n*pNum = 20 후:" << std::endl;
std::cout << "변수 num의 값: " << num << std::endl; // 출력: 20 (num의 값이 변경됨)
std::cout << "포인터 pNum이 가리키는 값: " << *pNum << std::endl; // 출력: 20
return 0;
}
nullptr
(널 포인터)
포인터 변수는 선언될 때 특정 주소로 초기화되지 않으면 쓰레기 값을 가집니다.
이런 포인터는 어디를 가리키는지 알 수 없으므로, 사용하면 심각한 오류를 유발할 수 있습니다.
안전한 프로그래밍을 위해, 포인터가 유효한 주소를 가리키지 않거나 아직 가리킬 주소가 없을 때는 nullptr
(C++11부터 도입)로 초기화하는 것이 좋습니다.
nullptr
은 C++에서 널 포인터를 나타내는 키워드입니다.
C 언어 스타일에서는 NULL
매크로(일반적으로 0으로 정의됨)를 사용했지만, C++에서는 nullptr
이 타입 안전성을 제공하므로 더 권장됩니다.
int* ptr = nullptr; // 아무것도 가리키지 않는 포인터로 초기화
if (ptr == nullptr) {
std::cout << "ptr은 현재 아무것도 가리키지 않습니다." << std::endl;
}
유효하지 않은 nullptr
을 역참조(*nullptr
)하려고 하면 프로그램이 충돌합니다 (segmentation fault
또는 access violation
). 따라서 포인터를 사용하기 전에 nullptr
인지 아닌지 항상 확인하는 습관을 들이는 것이 중요합니다.
포인터와 메모리 주소 크기
모든 포인터 변수는 자신이 가리키는 데이터 타입에 상관없이 동일한 크기를 가집니다.
포인터는 "주소"를 저장하는 변수이기 때문에, 그 주소를 표현하는 데 필요한 비트 수가 포인터의 크기가 됩니다.
예를 들어 64비트 시스템에서는 주소를 표현하는 데 8바이트(64비트)가 필요하므로, int*
, double*
, char*
모두 8바이트 크기를 가집니다.
#include <iostream>
int main() {
int* pInt;
double* pDouble;
char* pChar;
std::cout << "sizeof(int*): " << sizeof(pInt) << " bytes" << std::endl;
std::cout << "sizeof(double*): " << sizeof(pDouble) << " bytes" << std::endl;
std::cout << "sizeof(char*): " << sizeof(pChar) << " bytes" << std::endl;
// 대부분의 64비트 시스템에서 위 세 줄 모두 8 출력 (바이트 단위)
// 32비트 시스템에서는 4 출력
return 0;
}
하지만 포인터가 가리키는 대상의 타입(int
, double
, char
등)은 컴파일러가 포인터 연산을 수행하거나 역참조할 때 해당 타입의 크기만큼 메모리를 읽거나 쓰도록 지시하는 데 사용됩니다.
왜 포인터를 사용하는가?
포인터는 직접 메모리에 접근하는 강력한 도구이며 다음과 같은 상황에서 필수적으로 사용됩니다.
- 동적 메모리 할당(Dynamic Memory Allocation): 프로그램 실행 중에 필요한 만큼 메모리를 할당하고 해제할 때 (
new
,delete
연산자, 다음 장에서 학습). - 배열 및 문자열 처리: 배열은 본질적으로 포인터와 밀접하게 관련되어 있으며, C-스타일 문자열은
char
포인터로 다룰 수 있습니다. - 함수로 데이터 전달 (Pass-by-Pointer): 함수가 큰 데이터를 복사하지 않고 원본 데이터를 직접 수정해야 할 때 (값에 의한 전달의 단점 보완, 다음 장에서 학습).
- 복잡한 자료 구조 구현: 연결 리스트, 트리, 그래프 등 메모리상에 연속적이지 않은 데이터를 연결하여 구성하는 자료 구조를 만들 때 포인터가 사용됩니다.
- 하드웨어 제어, 운영체제와의 상호작용 등 저수준 프로그래밍.
포인터는 C++의 깊은 이해를 위한 필수적인 관문입니다.
처음에는 어렵게 느껴질 수 있지만, 개념을 명확히 하고 꾸준히 연습하는 것이 중요합니다.