나만의 작은 도서관
[C++] 스마트 포인터(Smart Pointer) 본문
스마트 포인터 탄생의 배경: 기존 포인터의 두 가지 문제점
문제점 1: 메모리 누수가 발생하기 쉬운 환경
- C++은 GC(Garbage Collector)가 없다. 따라서 프로그램이 종료되지 않는 한 동적 할당된 메모리는 자동으로 해제되지 않는다.
💡 C++이 GC를 사용하지 않는 이유는 GC를 사용하면 프로그램의 속도가 느려지기 때문입니다. C++은 그 무엇보다 성능을 우선시하기 때문에 GC를 사용하는 대신 직접 메모리를 관리하는 방식을 택했습니다.
- 만약 동적 할당한 메모리를 제대로 해제하지 않는다면 사용되지 않지만 자리를 차지하는 메모리, 즉, 가비지(Garbage)가 쌓이게 되고, 가비지가 쌓이게 되면 시스템에서 사용가능한 메모리 자원이 점점 줄어들게 되어 결국 메모리 부족으로 시스템은 크래시가 발생한다.
- 여기서 가비지가 쌓이는 현상을 “메모리 누수(Memory Leak)”라고 부르는데, 이러한 메모리 누수는 특히 서버처럼 장시간 실행되는 프로그램에 치명적인 문제가 된다.
- 이러한 이유로 C++에서의 동적 메모리 해제는 프로그래머가 굉장히 신경을 많이 써야 하는 부분이지만, 기존 포인터는 메모리 누수에 대한 안전장치가 없기 때문에 프로그래머가 메모리 누수 실수를 발생시킬 여지를 많이 주게 된다.
메모리 누수 예시
- 만약 발견된 버그에 대한 예외 처리 코드를 추가하는 상황을 가정한다면, 아래와 같이 예외처리 코드를 추가할 수 있을 것이다. 하지만 이때, 객체 메모리 해제 코드가 뒤에서 실행되어야 한다는 사실을 간과한다면 그대로 메모리 누수가 발생하게 된다.
class A{
public:
A() { std::cout << "객체 생성\\n"; }
~A() { std::cout << "객체 소멸\\n"; }
};
// 객체 생성 → 예외 발생 시 처리 → 객체 메모리 해제
void do_something(){
A *pa = new A();
/* 예외가 있다면 예외 처리*/
if (expection_condition){
throw 1;
}
/* --------------------*/
delete pa; // 예외 발생 시 해당 코드 실행 X -> pa 메모리 해제 X(메모리 누수)
}
int main(){
try{
do_something();
}
catch (int i){
std::cout << "예외 발생!" << std::endl;
// 예외 처리
}
}
// 실행 결과(예외 발생 시)
// 객체 생성!
// 예외 발생!
문제점 2: 더블 프리(Double Free)가 발생하기 쉬운 환경
- 메모리 누수가 메모리를 해제(delete) 하지 않아서 발생한 문제라면, 더블 프리는 해제한 메모리를 다시 해제하려고 할 때 발생한다.
- 더블 프리는 할당된 메모리의 크기 정보가 저장된 메모리 관리 헤더가 날아간 상태에서 해제를 시도하기 때문에 1) 헤더 정보가 없어 크래시가 나거나, 2) 전혀 관련 없는 다른 코드에서 할당받아 사용 중이던 해당 메모리를 해제해 버리는 문제가 발생한다. 어떤 경우이든 굉장히 심각한 문제이기 때문에 더블 프리는 절대로 발생하면 안 된다.
- 당연하게도 더블 프리를 프로그래머가 의도적으로 발생시키진 않는다. 그럼에도 더블 프리 실수가 발생하는 이유는 객체를 가리키는 포인터가 여러 개 있는 상황에서 어느 포인터가 객체의 소유권(ownership)을 가지고 있는지 명확히 하지 않았기 때문이다.
- 문제는 기존 포인터 문법으로는 어느 포인터가 객체의 소유권을 가지는지 정의할 수 없다는 것이다. 따라서 기존 포인터를 사용할 땐 코드의 구조를 통해 객체의 소유권을 식별해야 하는데, 이러한 환경은 프로그래머가 코드를 잘못 이해하여 더블 프리 실수를 발생시킬 여지를 주게 된다.
더블 프리 예시
- 아래 코드는 간단하게 더블 프리가 발생하는 과정을 보여준다. 당연히 아래와 같이 대놓고 더블 프리가 발생하는 코드를 작성하지는 않겠지만, 코드가 복잡해질수록 더블 프리의 발생 가능성을 놓치기 쉬워진다.
#include <iostream>
class Test {
public:
Test() { std::cout << "객체 생성\\n"; }
~Test() { std::cout << "객체 소멸\\n"; }
};
int main() {
Test* ptr1 = new Test(); // 객체 생성. (의도상 객체의 소유권을 가짐)
Test* ptr2 = ptr1; // ptr2도 같은 객체를 가리킴
delete ptr2; // ptr2가 먼저 해제 (의도상 객체의 소유권이 없지만 해제)
delete ptr1; // ptr1이 나중에 해제 (더블 프리 발생!)
return 0;
}
// 출력 결과
// 객체 생성
// 객체 소멸
// (오류: 더블 프리 또는 메모리 충돌 발생)
스마트 포인터(smart pointer)
- 스마트 포인터는 포인터를 래핑 하여 알맞은 정책에 따라 관리하는 “클래스”이다. 즉, 스마트 포인터를 선언하면 포인터를 객체의 형태로 사용하게 된다.
- 현대적인 C++(Modern C++)에서는 위와 같은 문제(메모리 누수, 더블 프리)로 기존 포인터를 직접적으로 사용하지 않고 스마트 포인터를 이용하여 간접적으로 사용하는 것이 일반적이다.
- C++에서 기존 포인터를 사용하는 일은 거의 없다고 생각하면 된다. C++ 기반인 언리얼 엔진에서도 전부 스마트 포인터를 사용한다.
- 스마트 포인터의 아래와 같이 총 3가지가 있으며, <memory> 헤더에 정의되어 있다.
스마트 포인터의 종류
- unique_ptr
- shared_ptr
- weak_ptr
💡 스마트 포인터 사용 시 주의 사항
웬만하면 생 포인터(raw pointer, 기존 포인터를 의미)와 섞어 쓰면 안 된다. 섞어 쓰는 순간 생 포인터로 인해 스마트 포인터의 관리 영역을 벗어난 예기치 않은 동작(예: 더블 프리, 메모리 누수)이 발생할 수 있다.
스마트 포인터의 공통된 기능 : RAII(Resource Acquisition Is Initialization)
- RAII는 C++에서 자원(메모리)을 관리하는 디자인 패턴 중 하나로, 스택에 할당한 객체를 통해 자원(메모리)을 관리하는 방식을 의미한다.
- RAII 패턴을 사용하면 예외가 발생하는 상황처럼 갑작스레 함수를 빠져나가도, 함수 내에서 RAII 패턴으로 선언된 모든 객체들은 빠짐없이 소멸자가 호출된다.
- 모든 종류의 스마트 포인터에는 RAII 패턴이 내장되어 있기 때문에, RAII 패턴의 특성에 의해 스마트 포인터는 명시적으로 delete 연산자를 사용하지 않으며, 기존 포인터에서 발생하는 메모리 누수 문제가 발생하지 않는다.
RAII 패턴 예제
#include <iostream>
using namespace std;
template <typename T>
class Wrapper { /* RAII 패턴 */
public:
Wrapper(const string& str) { _obj = new T(str);}
~Wrapper() { delete _obj;} // 사용한 자원을 해제
private:
T* _obj;
};
class A { /* 임시 객체 */
public:
A(const string& str) : _str(str) {cout << "A 객체 생성: " << _str << '\\n';}
~A() {cout << "A 객체 소멸: " << _str << '\\n';}
private:
string _str;
};
int main(void){ /* 메인 함수 */
try{
Wrapper<A> wrapper("RAII"s);
A* pa = new A("base pointer"s);
throw 1;
delete pa;
}
catch(int errCode){
cout << "예외 발생!" << '\\n';
}
return 0;
}
실행 결과
- 아래 결과처럼 코드 실행 도중 빠져나와도, RAII 패턴의 객체는 자동으로 소멸자가 호출된다.
A 객체 생성: RAII
A 객체 생성: base pointer
A 객체 소멸: RAII
예외 발생!
unique_ptr - 단독 소유 스마트 포인터
// A* pa = new A();
unique_ptr<A> pa(new A());
- 스마트 포인터 종류 중 하나인 unique_ptr은 특정 객체에 대한 유일한 소유권(ownership)을 부여한다.
- 특정 객체에 대한 unique_ptr은 오로지 하나이며, 이러한 특성에 의해 unique_ptr은 더블 프리를 예방하는 효과가 있다.
- unique_ptr은 복사할 수 없으며, 이동(move)만 가능하다.
- unique_ptr이 복사될 수 없는 이유는 내부에 복사 생성자가 “=delete”로 인해 삭제되었기 때문이다. 생성자를 “=delete”로 삭제해 두면 해당 생성자는 아예 사용할 수 없게 된다. (컴파일 시점부터 오류가 난다)
- make_unique(C++14) 함수는 unique_ptr을 반환하는 함수로, 사용 시 문법이 간결해지며 인자들 또한 완벽한 전달 방식으로 전달된다.
- make_unique() 사용 시 값이 기본값으로 초기화된다. 예를 들어 기본 타입은 0으로, 객체는 기본 생성자로 초기화한다.
// std::unique_ptr<Foo> ptr(new Foo(3, 5));
auto ptr = make_unique<Foo>(3, 5); // unique_ptr을 반환
unique_ptr 예제
#include <iostream>
#include <memory>
using namespace std;
class A{
public:
A(){ /* Constructor */
cout << "자원을 획득!" << '\\n';
data = new int[100];
}
~A(){ /* Destructor */
cout << "자원을 해제!" << '\\n';
delete data;
}
void func(){
cout << "일반 포인터와 동일하게 사용가능" << '\\n';
}
private:
int *data;
};
void doSomething(){
unique_ptr<A> pa(new A()); // 유니크 "포인터"
pa->func();
// unique_ptr복사 시도
// unique_ptr<A> pa2(pa); // 컴파일 오류: 삭제된 함수입니다.
}
int main(void){
doSomething();
return 0;
}
실행 결과
자원을 획득!
일반 포인터와 동일하게 사용가능
자원을 해제!
unique_ptr 사용 시 주의사항
- unique_ptr은 한 개의 포인터만 객체를 소유하도록 강제한다. 하지만 이는 단순히 복사를 금지할 뿐, 같은 객체를 가리키는 unique_ptr의 생성을 막진 않는다.
- 따라서 생 포인터(raw pointer, 기존 포인터를 의미)나 아래 코드처럼 get() 함수를 사용하면 같은 객체에 대한 또 다른 unique_ptr의 생성이 허용되는데, 만약 이러한 방식으로 unique_ptr을 생성했다면, 서로 다른 unique_ptr이 동일한 객체의 메모리를 관리하게 되면서 unique_ptr을 사용한 의도가 무색하게 더블 프리(double free)가 발생한다.
#include <iostream>
#include <memory>
using namespace std;
class A {
public:
A(const string& str) : _str(str) {cout << "A 객체 생성: " << _str << '\\n';}
~A() {cout << "A 객체 소멸: " << _str << '\\n';}
private:
string _str;
};
int main(void){
unique_ptr<A> pa(new A("unique_ptr"));
// get() 함수는 스마트 포인터가 들고있는 내부 포인터의 주소를 반환한다.
cout << "pa 주소: "<< pa.get() << '\\n';
{
unique_ptr<A> pa2(pa.get()); // 같은 객체를 가리키는 새로운 unique_ptr 생성 (잘못된 사용!)
cout << "pa2 주소: "<< pa2.get() << '\\n';
}
return 0;
}
// 실행 결과
// A 객체 생성: unique_ptr
// pa 주소: 0x771c40
// pa2 주소: 0x771c40
// A 객체 소멸: unique_ptr
// A 객체 소멸:
unique_ptr를 함수의 인자로 넘겨주기
- unique_ptr은 복사할 수 없기 때문에 함수의 인자로 넘길 때 기본 방식인 값 복사 방식(call by value)으론 넘겨줄 수 없다. 따라서 unique_ptr을 함수의 인자로 넘길 경우 참조 방식(&)을 통해 넘겨줘야 한다.
- 만약에 해당 함수로 소유권을 완전히 이동하고자 한다면 참조 방식 대신 move를 통해 이동시킨다.
- move 함수로 소유권이 이전된 후 남아있는 unique_ptr은 댕글링 포인터(dangling pointer)가 되기 때문에, 프로그래머는 이후에 해당 unique_ptr을 재참조를 하는 상황이 발생하지 않도록 해야 한다.
// unique_ptr을 참조(&)로 전달 → 소유권 유지
void useUniquePtr(const unique_ptr<A>& ptr) {
if (ptr) {
ptr->func(); // 소유권을 유지한 채 객체 사용 가능
}
}
// unique_ptr을 이동(move)하여 함수로 전달 (소유권 이전)
void takeOwnership(unique_ptr<A> ptr) {
ptr->func();
}
unique_ptr<A> ptr1 = make_unique<A>();
useUniquePtr(ptr1); // 참조 전달 (소유권 유지)
unique_ptr<A> ptr2 = make_unique<A>();
takeOwnership(move(ptr2)); // 소유권 이전 (이전 후 ptr2는 nullptr)
// ptr2->func() // 이전한 포인터 재참조 금지!
shared_ptr – 공유 소유 스마트 포인터
// A* pa = new A();
shared_ptr<A> pa(new A());
shared_ptr<A> p2(p1); // p2와 p1은 같은 객체를 가리킨다.
- 스마트 포인터 종류 중 하나인 shared_ptr은 동일한 객체를 가리키는 shared_ptr의 개수를 추적하며, 개수가 0이 되었을 때 해당 객체의 메모리를 해제하는 포인터이다.
- 이때 특정 객체를 가리키는 shared_ptr의 개수를 "참조 개수(reference count)"라고 부른다.
- unique_ptr과 달리 shared_ptr은 복사 생성자가 존재하므로 복사가 가능하다.
- make_shared(C++11) 함수는 shared_ptr을 반환하는 함수로, 사용 시 문법이 간결해지며 성능면에서도 약간의 이점이 있다.
shared_ptr<A> p1(new A());
shared_ptr<A> p1 = make_shared<A>(); // shared_ptr을 반환
make_shared 함수 사용 시 성능적 이점이 있는 이유
- “shared_ptr<A> p1(new A());”과 같은 방식으로 shared_ptr을 생성 시 두 번의 할당이 발생한다. (A 객체를 생성하기 위한 동적 할당 한 번, shared_ptr을 생성하기 위한 동적 할당 한 번)
- 반면 make_shared 방식으로 shared_ptr을 생성 시, shared_ptr에 필요한 제어 블록(control block)이 한 번에 만들어지기 때문에 동적 할당이 한 번만 수행된다. (참고로 동적할당은 상당히 비싼 연산이다.)
- 따라서, make_shared 함수를 사용하면 동적 할당 횟수가 1번 줄어드므로 성능이 더 좋다.
참조 개수(Reference Count, 줄여서 refCount)
- 참조 개수는 같은 객체를 가리키고 있는(참조하고 있는) 대상의 개수를 의미한다.
- shared_ptr에서의 참조 개수는 같은 객체를 가리키고 있는 shared_ptr의 개수를 의미하며, 이를 “강한 참조 개수”라고 부른다.
- 강한 참조 개수는 shared_ptr을 복사할 때마다 1 증가하며, shared_ptr이 소멸될 때마다 1 감소한다.
- 만약 모든 shared_ptr이 소멸되어 강한 참조 개수가 0이 되었다면, 해당 객체의 메모리를 해제한다.
- 객체에 대한 참조 개수는 "제어 블록(control block)"에서 저장되어 있다.
제어 블록(control block)
- 제어 블록이란 스마트 포인터 중 shared_ptr, weak_ptr이 활용하는 구조체로, 참조 개수와 같은 객체의 메모리 관리 정보를 저장하고 있다.
- 제어 블록은 객체를 주소를 통해 shared_ptr이 생성될 때 같이 생성되며, 참조 개수는 1로 설정된다.
- 제어 블록은 shared_ptr을 복사한 shared_ptr들과 공유되며, 공유된 제어 블록을 통해 각 shared_ptr은 참조 개수가 동기화된다.
shared_ptr 생성 시 주의사항
- 새로운 shared_ptr 생성 시 기존의 shared_ptr을 복사하는 방식이 아닌 다른 방식으로 생성하지 않도록 주의해야 한다.
- 만약 기존 shared_ptr을 복사하지 않고 다른 방식으로 생성할 경우, 동일한 객체에 대해 또 다른 제어 블록이 생성되어 참조 개수 추적이 올바르게 되지 않는다.
- 이렇게 제어 블록이 여러 개 생긴 상황에서 한 제어 블록의 참조 개수가 0이 되면, 해당 객체의 메모리가 해제되고, 결국 다른 제어 블록을 참조하는 shared_ptr들이 댕글링 포인터가 되는 끔찍한 상황이 벌어진다.
// 이렇게 따로 shared_ptr을 생성하면 안된다
shared_ptr<A> pa1(a); // control block X
shared_ptr<A> pa2(a); // control block Y
순환 참조(Circular Reference) 문제: 서로 참조하는 shared_ptr
- 순환 참조란 두 개 이상의 객체가 서로를 참조하는 shared_ptr를 들고 있어 참조 사이클이 발생한 경우를 의미한다.
- 순환 참조가 발생할 경우, 각 객체의 참조 개수가 영원히 0이 되지 않으며, 이로 인해 객체의 메모리가 해제되지 않아 메모리 누수가 발생한다.
- 순환 참조가 발생하는 예로, 상대방 위치 정보를 얻기 위해 서로의 플레이어 객체를 가리키는 포인터를 shared_ptr로 들고 있는 경우가 있다. 예시 코드는 아래와 같다.
class Player; // 전방 선언
class Player {
public:
string name;
float posX; // x 좌표
float posY; // y 좌표
float posZ; // z 좌표
shared_ptr<Player> targetPlayer; // 상대방을 shared_ptr로 가리킴
Player(const string& n) : name(n) { }
~Player() { }
void setTarget(shared_ptr<Player> target) {
targetPlayer = target;
}
};
shared_ptr<Player> player1("p1"s);
shared_ptr<Player> player2("p2"s);
player1->setTarget(player2);
player2->setTarget(player1);
순환 참조가 메모리 누수를 발생시키는 과정
1. 서로를 참조하는 두 객체를 생성
- 객체 1과 객체 2가 동적 할당한 메모리 주소를 shared_ptr에 저장한 다음, 멤버 변수로 가지고 있던 shared_ptr를 통해 서로를 참조하도록 설정한 모습이다.
1. 서로를 참조하는 두 객체를 생성
2. 객체 하나가 소멸됨(객체 1 소멸)
- 객체 1이 소멸되어 객체 1을 가리키는 제어 블록의 참조 개수가 1 감소한다. 하지만 객체 2의 shared_ptr 멤버 변수가 객체 1을 가리키고 있으므로 객체 1의 메모리는 해제되지 않는다.
3. 남은 객체가 소멸됨(객체 2 소멸)
- 객체 2가 소멸되어 객체 2를 가리키는 제어 블록의 참조 개수가 1 감소한다. 하지만 객체 1의 shared_ptr 멤버 변수가 객체 2를 가리키고 있으므로 객체 2의 메모리는 해제되지 않는다.
- 즉, 객체 1과 객체 2는 서로의 메모리가 먼저 해제되어야 참조 개수가 0이 되는 구조를 가지게 되어(데드락과 비슷한 양상), 두 객체의 참조 개수는 영원히 0이 되지 않는다.
순환 참조 예제
#include <iostream>
#include <memory>
using namespace std;
class A
{
public:
int *data;
shared_ptr<A> other;
public:
A(){
data = new int[100];
cout << "자원을 획득함!" << '\n';
}
~A(){
cout << "소멸자 호출!" << '\n';
delete[] data;
}
void set_other(shared_ptr<A> o) { other = o; }
};
int main()
{
A *tempPa, *tempPb;
{
shared_ptr<A> pa = std::make_shared<A>();
shared_ptr<A> pb = std::make_shared<A>();
pa->set_other(pb);
pb->set_other(pa);
tempPa = pa.get();
tempPb = pb.get();
cout << tempPa->other.use_count() << '\n';
cout << tempPb->other.use_count() << '\n';
}
cout << tempPa->other.use_count() << '\n';
cout << tempPb->other.use_count() << '\n';
}
실행 결과
- 각 객체의 참조 개수를 확인해 보니 예상대로 객체를 가리키는 shared_ptr이 소멸되었음에도 두 객체 모두 참조 개수가 1로 남아있음을 확인할 수 있다.
자원을 획득함!
자원을 획득함!
2
2
1
1
순환 참조 해결 방법
- 순환 참조를 해결하기 위해선 참조 사이클을 끊어야 한다.
- 참조 사이클을 끊기 위해선 1) 사이클에 포함된 shared_ptr을 제거하거나, 2) shared_ptr을 weak_ptr(뒤에서 설명)로 바꿔 사용해야 한다.
enable_shared_from_this
- 스마트 포인터, 특히 shared_ptr로 관리하는 객체들은 참조 개수 추적이 되지 않는 생 포인터(raw pointer)와 혼용해서 사용하면 안 된다. 따라서, this는 생 포인터이므로 shared_ptr로 관리되는 상황에선 그대로 사용할 수 없다.
- this를 사용하기 위해서 “shared_ptr<A>(this)”와 같이 작성할 수도 없다. 이는 이미 존재하는 shared_ptr을 복사하는 방식이 아니기 때문에 “shared_ptr<A>(this)”가 shared_ptr을 반환할 때마다 새로운 제어 블록을 생성하고, 소멸될 때마다 메모리 해제를 시도하는 더 끔찍한 상황이 된다.
- enable_shared_from_this는 위와 같은 문제를 해결해 주는 "클래스"로, 상속 시 파생 클래스는 추가되는 다음 두 개의 함수를 통해 this를 올바른 방식으로 shared_ptr 또는 weak_ptr로 사용할 수 있게 된다.
- shared_from_this() : 이미 생성되어 있는 shared_ptr의 제어 블록을 통해 this를 래핑 한 shared_ptr을 반환한다.
- weak_from_this() : 이미 생성되어 있는 weak_ptr의 제어 블록을 통해 this를 래핑한 weak_ptr을 반환한다.
enable_shared_from_this 사용 예제
#include <iostream>
#include <memory>
using namespace std;
class A : public enable_shared_from_this<A> {
public:
A(){
data = new int[100];
cout << "자원을 획득함!" << '\n';
}
~A(){
cout << "소멸자 호출!" << '\n';
delete[] data;
}
/* shared_from_this()를 이용해 this를 shared_ptr로 반환 */
shared_ptr<A> get_shared_ptr() { return shared_from_this(); }
private:
int *data;
};
int main(){
shared_ptr<A> pa1 = make_shared<A>();
shared_ptr<A> pa2 = pa1->get_shared_ptr();
cout << pa1.use_count() << '\n';
cout << pa2.use_count() << '\n';
}
실행 결과
자원을 획득함!
2
2
소멸자 호출!
shared_from_this() 사용 시 주의사항
- shared_from_this()를 사용할 땐 한 가지 주의사항이 있는데, shared_from_this()을 사용하려면 반드시 해당 객체를 가리키는 shared_ptr이 이미 존재해야만 한다는 것이다. 왜냐하면 shared_from_this()는 이미 존재하는 제어 블록을 확인할 뿐, 새로운 제어블록을 생성하지 않기 때문이다.
A* a = new A();
std::shared_ptr<A> pa1 = a->get_shared_ptr(); // 이미 존재하는 shared_ptr이 없으므로 오류 발생
weak_ptr – 비소유 참조 포인터
// A* sp = new A();
shared_ptr<A> sp = make_shared<A>();
weak_ptr<A> wp = sp;
- weak_ptr은 일반 포인터와 shared_ptr 사이에 위치한 스마트 포인터로, 이미 존재하는 shared_ptr의 제어 블록을 공유받아 생성한다.
- weak_ptr은 복사한 shared_ptr과 같은 제어 블록을 공유하며, shared_ptr 복사 시 제어 블록의 “강한 참조 개수”를 증가시키는 대신, “약한 참조 개수”를 증가시킨다.
- 약한 참조 개수란 해당 객체를 가리키는 weak_ptr의 개수를 의미한다.
- 위 코드를 예시로 들면, sp와 wp는 동일한 제어 블록을 공유하며, wp는 sp로부터 공유받은 제어 블록의 약한 참조 개수를 1 증가시킨다.
- weak_ptr이 객체를 참조하고 있음에도 불구하고 모든 shared_ptr이 소멸되어 강한 참조 개수가 0이 되었다면 해당 객체의 메모리는 해제된다. 이로 인해 weak_ptr이 댕글링 포인터가 된 상태로 객체에 접근할 수 있으므로, C++은 weak_ptr로 객체를 직접 접근하지 못하게 막고, shared_ptr로 변환하여 사용하는 방식만 허용한다. 따라서 weak_ptr은 아래 코드와 같이 사용한다.
// weak_ptr 사용 방법 2가지
shared_ptr<A> o = wp.lock(); // lock(): weak_ptr을 shared_ptr로 변환
if(o) { /* 아직 살아있는 객체인 경우 집입*/}
else { /* 이미 소멸된 객체인 경우 진입 */ }
또는
if(!wp.expired()) { // expired(): 참조하는 객체의 소멸 유무를 bool타입으로 반환
/* 아직 살아있는 객체인 경우 집입*/
shared_ptr<A> o = wp.lock();
}
else { /* 이미 소멸된 객체인 경우 진입 */ }
- 이렇듯 weak_ptr은 shared_ptr보다 사용 과정이 번거롭다는 단점이 있지만, 객체의 생명 주기로부터 자유롭게 사용할 수 있다는 장점이 있다(객체 소멸에 관여하지 않기 때문).
weak_ptr을 사용하는 이유: 순환 참조 문제 해결
- 서로의 정보를 알기 위해 shared_ptr로 각 객체가 서로를 참조하는 경우, 참조 개수가 영원히 0이 되지 않는 “순환 참조 문제”가 발생한다.
- 이때 참조 사이클에 포함된 일부 shared_ptr을 weak_ptr로 바꿔 사용하면 weak_ptr은 강한 참조 개수를 증가시키지 않기 때문에 weak_ptr이 가리키는 객체의 참조 개수가 0이 되어 순환 참조 문제가 발생하지 않는다.
weak_ptr을 이용한 순환 참조 문제 해결 예시
#include <memory>
#include <iostream>
using namespace std;
class B; // 전방 선언
class A {
public:
A() { cout << "A 객체 생성" << '\\n'; }
~A() { cout << "A 객체 파괴" << '\\n'; }
void setOther(shared_ptr<B> other) { _other = other; }
private:
shared_ptr<B> _other;
};
class B {
public:
B() { cout << "B 객체 생성" << '\\n'; }
~B() { cout << "B 객체 파괴" << '\\n'; }
void setOther(shared_ptr<A> other) { _other = other; }
private:
weak_ptr<A> _other; // weak_ptr로 순환 참조 방지
// shared_ptr<A> _other; // 순환 참조 발생
};
int main() {
shared_ptr<A> pa = make_shared<A>();
shared_ptr<B> pb = make_shared<B>();
pa->setOther(pb);
pb->setOther(pa);
return 0;
}
실행 결과
A 객체 생성
B 객체 생성
A 객체 파괴
B 객체 파괴
weak_ptr은 해당 객체의 메모리가 해제되었다는 사실을 어떻게 알까?
- 강한 참조 개수가 0이 되어 해당 객체의 메모리가 해제되어도 제어 블록은 약한 참조 개수가 0이 아니라면 삭제되지 않는다.
- 따라서, weak_ptr은 객체의 메모리가 해제되어도 제어 블록을 참조할 수 있기 때문에 메모리가 해제되었다는 사실을 알 수 있다.
기타 내용 1: 스마트 포인터 함수들
- 전부는 아니지만 각 스마트 포인터에서 사용하는 함수들을 표로 정리해 보았다.
unique_ptr 함수들
함수 | 설명 |
get() | 내부의 생 포인터를 반환 |
release() | 포인터의 소유권을 포기하고 생 포인터를 반환 (소멸자는 호출되지 않음) |
reset() | 포인터를 삭제하고 새로운 객체로 교체 |
swap() | 다른 unique_ptr과 내부 포인터를 교체 |
operator* / operator-> | 포인터처럼 객체에 접근 |
make_unique<T>() (C++14~) | 객체를 안전하게 생성해 unique_ptr 반환 |
shared_ptr 함수들
함수 | 설명 |
use_count() | 현재 객체를 참조하는 shared_ptr의 개수(참조 카운트)를 반환 |
unique() | 참조 카운트가 1이면 true |
get() | 내부의 생 포인터를 반환 (소유권은 유지됨) |
reset() | 소유권을 해제하거나 새 객체로 변경 |
swap() | 다른 shared_ptr과 내부 포인터를 교체 |
operator* / operator-> | 포인터처럼 객체에 접근 |
make_shared<T>() (비멤버 함수) | 객체와 제어 블록을 한 번에 효율적으로 생성 |
weak_ptr 함수들
함수 | 설명 |
lock() | 객체가 살아 있다면 shared_ptr을 반환, 그렇지 않으면 nullptr |
expired() | 참조하는 객체가 이미 파괴되었는지 확인 |
use_count() | 연결된 shared_ptr의 수 반환 (자기 소유 아님) |
reset() | 내부 포인터 제거 |
swap() | 다른 weak_ptr과 교체 |
기타 내용 2: 스마트 포인터의 캐스팅
- 생 포인터에서 다른 타입의 생 포인터로 캐스팅할 수 있는 것처럼, C++에서 shared_ptr과 weak_ptr를 위한 아래 4가지 캐스팅 함수를 제공한다. (물론 캐스팅할 수 있는 타입에 제약이 있다.)
스마트 포인터 캐스팅 함수
함수 | 설명 |
static_pointer_cast<T>(ptr) | 컴파일 타임 타입 변환 (업캐스팅/다운캐스팅 가능) |
dynamic_pointer_cast<T>(ptr) | 런타임 타입 검사 포함된 안전한 다운캐스팅 (T가 다형 타입이어야 함) |
const_pointer_cast<T>(ptr) | const 제거 |
reinterpret_pointer_cast<T>(ptr) | 비트 단위로 재해석하는 위험한 변환 (권장 ❌) |
참고 자료
'C++ > 문법 및 메소드(STL)' 카테고리의 다른 글
[C++][Callback] #4. 람다 표현식(Lambda Expression) (0) | 2025.04.03 |
---|---|
[C++][Callback] #3. 함수 객체(functor) (0) | 2025.04.03 |
[C++][Callback] #2. 함수 포인터(Function Pointer) (0) | 2025.04.02 |
[C++][Callback] #1. 콜백 함수와 Callable, 그리고 std::function (0) | 2025.04.02 |
[C++] 전방 선언(Forward Declaration) (0) | 2025.03.28 |