메모리 주소를 통한 접근 및 제어
변수와 메모리 주소
변수는 값(value)을 저장하는 메모리 공간의 이름이며, 할당받은 메모리 공간의 시작 위치(주소)를 가리킨다. 주소는 정수값이며 보통 16진수로 표현한다.
int a = 10;
std::cout << &a; // 0x7ffe1234
복사는 느리다
변수의 대입은 기본적으로 값을 복사한다.
int a = 10;
int b = a; // a의 값을 b에 복사
값이 복사되면, 복사한 값을 저장하는 새로운 공간이 생겨난다. 따라서 위 코드에서는, a와 b의 값은 동일한 10이지만 각각 서로 다른 메모리 공간에 독립적으로 존재한다. 작은 변수는 괜찮겠지만, 크기가 큰 경우에는 문제가 발생한다.
std::vector<int> v(1000000, 1);
std::vector<int> w = v; // 복사 -> 성능 저하
큰 데이터를 함수나 다른 변수로 넘길 때마다 복사하면 메모리 낭비 및 속도 저하가 심각해진다.
포인터(pointer)
이때, 메모리 주소를 직접 다룰 수 있는 수단이 포인터이다. 포인터는 다른 변수의 메모리 주소를 저장하는 변수이다. 즉, 값을 주는것이 아닌, 값이 어디 있는지를 알려주는 것이다.
int a = 10;
int* p = &a;
std::cout << p << std::endl; // 0x7ffe1234
std::cout << *p << std::endl; // 10
- int* p: 포인터 변수 p의 선언. int*는 int를 가리키는 포인터 타입이다.
- &a: 변수 a의 주소값. &은 주소 연산자로 피연산자의 주소값을 반환한다.
- p: 변수 a의 주소
- *p: 변수 a의 값. *는 역참조 연산자로 피연산자가 가리키는 주소에 저장된 값을 반환한다.
포인터의 타입
포인터는 단순히 주소를 저장하지만, 그 주소를 어떤 타입으로 해석할지를 지정해야 한다. 주소는 정확히 할당된 메모리 공간의 시작 위치이기 때문에, 시작 위치로부터 얼만큼, 어떻게 읽느냐에 따라 데이터의 크기와 해석이 달라진다.
int x = 65;
char* p = (char*)&x;
std::cout << *p; // 'A'
C++ 컴파일러는 기본적으로 포인터 타입간 암묵적 타입변환을 지원하지 않는다.
int x = 65;
char* p = &x; // error
메모리 해제
동적으로 메모리를 할당한 경우(힙 영역)에는 메모리가 자동으로 해제되지 않는다.
int* p = new int(10); // 힙 영역에 할당
//...
// delete p; 해제하지 않으면 메모리 누수 발생
개발자의 책임 하에 사용된다.
포인터는 자유롭게 메모리를 직접 접근하고 제어할 수 있지만, 그에 따른 위험 요소를 수반하며 책임이 따른다.
- 초기화되지 않은 포인터: 아무 주소나 가리킬 수 있음(쓰레기 값)
- NULL 포인터 역참조: 존재하지 않는 메모리 접근
- 메모리 누수: 동적으로 메모리를 할당한 경우 반드시 해제
- Dangling pointer: 이미 해제된 메모리를 가리키는 포인터 사용
- 타입 해석 오류: 잘못된 캐스팅으로 메모리를 해석
int* p; // 초기화 X
*p = 10; // 쓰래기 주소 참조
int* p = new int(42); // 힙 영역에 할당
delete p; // 메모리 해제
*p = 100; // 해제된 메모리 접근
포인터의 대안
기존 포인터는 메모리 주소를 직접 다루는 과정에서 실수가 발생하기 쉽다. 이러한 문제를 해결하기 위해, C++11부터 스마트 포인터(smart pointer)가 도입되었다. 스마트 포인터는 스코프를 벗어나면 자동으로 메모리를 해제해주는 객체이다.
- std::unique_ptr: 단일 소유 스마트 포인터
- std::shared_prt: 공동 소유 포인터
- std::weak_ptr: 순환 참조 방지
std::unique_ptr<int> p = std::make_unique<int>(10);
레퍼런스(reference)
스마트 포인터가 있음에도, 아직 포인터 문법 자체가 가진 복잡함과 안정성 문제(주소 접근)가 존재한다. 레퍼런스는 보다 간결하고 안전한 참조 방식으로써, 기존 변수에 별칭(alias)을 부여하여, 원본 데이터에 접근하고 수정할 수 있도록 한다.
int a = 10;
int& r = a; // r은 a의 또 다른 이름
r = 20; // a의 값이 20으로 변경됨
위 코드에서 r은 a를 부르는 또 다른 이름일 뿐이며, 별도의 주소 접근이나 복사가 일어나지 않는다.
동작 방식
레퍼런스는 내부적으로 상수 포인터와 유사한 방식으로 처리된다.
int a = 10;
int& r = a;
int* const r = &a;
*r = 20;
함수의 인자 전달 방식
함수의 인자를 전달하는 방식은 다음 세 가지로 나뉜다.
값 전달
호출 시 값이 복사되어 전달되며, 원본에 직접 접근하지 않는다. 작은 데이터에는 적합하지만, 큰 객체는 복사 비용이 크다.
void foo(int x) {
x = 100; // 원본 변경 X
}
가장 직관적이고 안전하지만, 성능상 손해가 있을 수 있다.
포인터 전달
주소를 전달하므로, 함수 안에서 원본에 직접 접근한다. nullptr 확인, *, & 등 포인터 문법의 복잡성이 존재한며, 메모리를 동적 할당한 경우 직접 해제 해줘야 한다.
void foo(int* p) {
*p = 100; // 원본 변경
}
유연하지만 위험하고 실수 유발 가능성 높음
참조 전달
포인터처럼 원본에 접근 가능하지만, 문법은 일반 변수처럼 깔끔하다.
void foo(int& x) {
x = 100; // 원본 변경
}
포인터보다 안전하고 직관적인 원본 접근 방식
스마트 포인터 전달
동적 객체의 소유권을 공유하거나 이전하면서 전달 가능하다. 메모리 해제는 스마트 포인터가 자동으로 처리(RAII)
void foo(std::shared_ptr<int> p) {
*p = 100;
}
포인터의 유연함 + 자동 메모리 관리