⊙ 스마트 포인터의 원리와 필요성에 대해 이해한다.
⊙ 스마트 포인터의 종류가 무엇이 있는지 안다.
⊙ unique_ptr, shared_ptr, weak_ptr의 차이와 특징을 이해한다.
스마트 포인터란?
동적으로 할당된 변수나 객체들을 다루면서 가장 주의해야 했던게 무엇인가? "메모리는 쓰고 잘 해제 했는지"일 것이다. 다 좋은데 자동으로 메모리를 관리해주지 않다보니 그 수고를 내가 해야만 한다. 이런 실수들 말이다.
- 댕글링 포인터(Dangling Pointer) 문제 : 메모리는 이미 해제 되었는데 아직 그곳을 가리키고 있는 포인터(=댕글링 포인터)를 이용해 해제된 메모리 공간에 모르고 접근하는 것
- 이중 해지(Double Free) 문제 : 이미 해제된 메모리를 또 해제하는 것
코드를 짜다보면 충분히 일어날 수 있는 일들이고 이걸 일일이 확인하고 있어야 한다고 생각하면 피곤한 일이 아닐 수가 없다. 그래서 이 피곤한 일을 대신 좀 해줄 스마트 포인터라는게 등장했다.
스마트 포인터는 메모리 관리를 자동화하고 메모리 누수를 방지하기 위해 설계된 클래스 템플릿이다. 동작은 일반 포인터와 비슷하게 하지만, 소멸자에서 할당된 메모리를 자동으로 해제하여 명시적인 메모리 해제를 할 필요 없게 도와준다.
스마트 포인터 원리
스마트 포인터의 핵심 원리는 레퍼런스 카운터이다. 레퍼런스 카운터에는 자신을 참조하고 있는 포인터의 개수가 저장되어 있다. delete를 직접 하는 대신 자신을 참조하고 있는 포인터의 개수가 0이 되면 자동으로 해지하는 것이다.
스마트 포인터의 종류
그 종류가 하나만도 아니다.
- unique_ptr
- shared_ptr
- weak_ptr
각각 다른 특징을 가지고 있기 때문에 그 내용은 아래서 자세히 다뤄보자.
스마트 포인터의 장점
- 메모리 누수 방지 : 명시적으로 delete를 호출할 필요가 없으며, 메모리가 자동으로 해제된다.
- 코드 가독성 향상 : 메모리 관리가 간단해지고 코드가 직관적으로 작성됩니다.
- 안전성 향상 : 동적 메모리를 잘못 관리하여 발생할 수 있는 버그를 줄인다.
unique_ptr
이름만 들어도 유니크 한게 유일한 뭔가가 있을 것 같은 스마트 포인터이다. unique_ptr은 동일한 메모리를 가리키는 두 개의 unique_ptr 인스턴스가 동시에 존재할 수 없도록 한다. 다르게 말하면 레퍼런스 카운터가 최대 1인 스마트 포인터라는 뜻이다. 따라서 소유권을 다른 사람에게 주는 것은 가능하지만, 동시에 두 개 이상 소유할 수는 없다.
unique_ptr 생성과 사용
#include <iostream>
#include <memory> // unique_ptr 사용
using namespace std;
int main() {
// unique_ptr 생성
unique_ptr<int> ptr1 = make_unique<int>(10); // 일반 변수
unique_ptr<MyClass> myObject = make_unique<MyClass>(42); // 객체
// 일반 포인터 생성 방법과 비교
// int* ptr1 = new int(10);
// unique_ptr이 관리하는 값 출력
cout << "ptr1의 값: " << *ptr1 << endl;
// MyClass의 멤버 함수 호출
myObject->display();
// 범위를 벗어나면 메모리 자동 해제
return 0;
}
unique_ptr을 사용하기 위해서는 생성을 해주어야 한다. 데이터형은 unique_ptr<데이터형>으로 하고 선언은 make_unique<데이터형>(값)으로 한다.
사용은 일반 포인터와 똑같이 역참조(*)연산자를 사용하면 똑같이 사용할 수 있는 것을 확인할 수 있다.
그리고 코드에 delete가 없는 것을 확인할 수 있다. 이게 아주 큰 변화인데 이젠 범위를 벗어나면 메모리가 자동 해제된다는 것이다.
move로 소유권 이전
다시 말하지만 동시에 둘이 소유하고 있을 수 없기 때문에 복사나 대입은 불가능하다. 이걸 시도하는 순간 컴파일 에러가 발생한다. 대신 소유권을 다른 사람에게 주는 것은 가능하다고 하였다. 소유권 이전은 move를 이용해야 한다.
#include <iostream>
#include <memory>
using namespace std;
int main() {
// unique_ptr 생성
unique_ptr<int> ptr1 = make_unique<int>(20);
unique_ptr<MyClass> myObject = make_unique<MyClass>(42);
// unique_ptr은 복사가 불가능
// unique_ptr<int> ptr2 = ptr1; // 컴파일 에러 발생!
// 소유권 이동 (move 사용)
unique_ptr<int> ptr2 = move(ptr1);
unique_ptr<MyClass> newOwner = move(myObject);
if (!ptr1) {
cout << "ptr1은 이제 비어 있습니다." << endl;
}
cout << "ptr2의 값: " << *ptr2 << endl;
if (!myObject) {
cout << "myObject는 이제 비어 있습니다." << endl;
}
newOwner->display();
return 0;
}
shared_ptr
이름부터 뭔가 나눠써도 될 것 같은 shared_ptr은 unique_ptr과 다르게 여러 shared_ptr 인스턴스가 동일한 메모리를 가질 수 있는 스마트 포인터다. 즉, 레퍼런스 카운터가 N개가 될 수 있다는 것이다. 따라서 이번에는 복사 혹은 대입이 가능하다.
shared_ptr 생성과 사용
#include <iostream>
#include <memory> // shared_ptr 사용
using namespace std;
int main() {
// shared_ptr 생성
shared_ptr<int> ptr1 = make_shared<int>(10); // 일반 변수
shared_ptr<MyClass> obj1 = make_shared<MyClass>(42); // 객체
// shared_ptr 사용
cout<<"값: "<< *ptr1 <<endl;
obj1->display();
// 범위를 벗어나면 ptr1, obj1도 자동 해제
return 0;
}
shared_ptr은 unique_ptr와 비슷한 모양으로 생성할 수 있다. 사용은 물론 일반 포인터와 비슷하게 사용도 가능하다.
소유권 공유/해제와 참조 카운트
unique_ptr과 다르게 이 친구는 다른 친구가 공간을 공유하자고 해도 선뜻 내주는 친구다. 복사 혹은 대입이 가능하다는 뜻이다. 사실 메모리 누수를 방지하겠다면서 상당히 대담한 선택이라고 할 수 있다. shared_ptr은 내부적인 카운팅을 수행하여 메모리를 가리키는 shared_ptr 인스턴스의 수를 추적한다. 그래서 우리가 직접 몇 개의 shared_ptr이 이곳을 가리키고 있는지도 볼 수 있다. use_count() 함수를 사용하면 된다.
그리고 참조하고 있는 여러 개의 인스턴스 중 원하는 인스턴스만 참조 해제하기 위해 reset() 함수도 제공한다. reset을 이용해 포인터를 초기화하고 나면 use_count 결과값이 달라지는 것을 확인할 수 있다.
#include <iostream>
#include <memory> // shared_ptr 사용
using namespace std;
int main() {
// shared_ptr 생성
shared_ptr<int> ptr1 = make_shared<int>(10);
// ptr1의 참조 카운트 출력
cout << "ptr1의 참조 카운트: " << ptr1.use_count() << endl; // 출력: 1
// ptr2가 ptr1과 리소스를 공유
shared_ptr<int> ptr2 = ptr1;
cout << "ptr2 생성 후 참조 카운트: " << ptr1.use_count() << endl; // 출력: 2
// ptr2가 범위를 벗어나면 참조 카운트 감소
ptr2.reset();
cout << "ptr2 해제 후 참조 카운트: " << ptr1.use_count() << endl; // 출력: 1
//===================================객체====================================
// shared_ptr로 MyClass 객체 관리
shared_ptr<MyClass> obj1 = make_shared<MyClass>(42);
// 참조 공유(객체)
shared_ptr<MyClass> obj2 = obj1;
cout << "obj1과 obj2의 참조 카운트: " << obj1.use_count() << endl; // 출력: 2
obj2->display(); // 출력: 값: 42
// obj2를 해제해도 obj1이 객체를 유지
obj2.reset();
cout << "obj2 해제 후 obj1의 참조 카운트: " << obj1.use_count() << endl; // 출력: 1
// 범위를 벗어나면 ptr1도 자동 해제
return 0;
}
리소스를 공유하고나면 use_count()가 하나 늘어나 있는 것을 확인할 수 있다. 대입 연산을 하면서 두 개의 스마트 포인터 변수 또는 객체가 같은 곳을 가리키게 되었고 그래서 use_count()로 레퍼런스 카운트를 확인하면 1->2로 변해있다.
반대로 reset()을 이용해서 해제하여주면 use_count()가 다시 1로 줄어든다. 하지만 아직 가리키고 있는 포인터가 하나 남았기에 남은 포인터로 접근이 가능한 것 까지 확인할 수 있다.
그래서 이걸 열심히 세서 뭘 하느냐 하면, 가리키고 있는 인스턴스의 수가 0이 되는 순간 메모라를 해제하려고 하는 것이다. 이렇게 여러 개의 인스턴스가 가리키고 있어도 메모리 누수 없이 데이터를 관리하고자 하는 것이다.
weak_ptr
weak_ptr은 shared_ptr의 중요한 동반자이며, 스마트 포인터가 순환 참조와 같은 문제에 대처하는데 큰 도움을 준다. 기본적으로는 shared_ptr과 비슷하지만, 가리키는 객체의 수명에 영향을 주지 않는 '약한' 참조를 제공한다는 점에서 다르다. 이는 순환 참조를 피하는데 유용하다.
순환 참조
순환참조, 순환참조 거리는데 도대체 무엇인가? 순환참조는 두 객체가 서로를 참조하고, 둘다 shared_ptr를 사용하여 참조를 유지하는 경우 발생한다. 이 경우 두 객체 모두 레퍼런스 카운트가 절대 0이 되지 않아 메모리 누수가 발생하게 된다.
struct B;
struct A
{
std::shared_ptr<B> b_ptr;
};
struct B
{
std::shared_ptr<A> a_ptr;
};
std::shared_ptr<A> a(new A());
std::shared_ptr<B> b(new B());
a->b_ptr=b;
b->a_ptr=a; // 순환 참조 생성
이런 상황일 경우 순환 참조가 생성되고 서로가 서로를 참조하는 바람에 누구도 레퍼런스 카운트가 0이 되지 않는 불상사가 발생한다.
순환 참조 해결
이러한 순환 참조를 해결할 수 있는 방법은 든든한 동반자 weak_ptr을 사용하는 것이다.
struct B;
struct A
{
std::shared_ptr<B> b_ptr;
};
struct B
{
std::weak_ptr<A> a_ptr; // weak_ptr 사용
};
std::shared_ptr<A> a(new A());
std::shared_ptr<B> b(new B());
a->b_ptr=b;
b->a_ptr=a; // 순환 참조가 발생하지 않음
B가 weak_ptr을 사용하여 A를 참조하고 있다. 이런 경우 순환 참조는 발생하지 않는다. A가 파괴되면 std::weak_ptr은 자동으로 nullptr로 설정된다.
shared_ptr로 변신
또한 weak_ptr은 lock() 함수를 사용하여 shared_ptr로 변환할 수도 있다. 이 함수는 해당 객체가 여전히 존재하는 경우에만 shared_ptr을 반환하므로, 메모리를 안전하게 접근할 수 있다.
if(std::shared_ptr<A> a_locked=b->a_ptr.lock())
{
// 객체가 여전히 존재하므로 안전하게 접근할 수 있다.
}
else
{
// 객체가 이미 파괴되었습니다.
}
배운 내용 정리
- 스마트 포인터는 메모리 관리를 자동화하고 메모리 누수를 방지하기 위해 설계된 클래스 템플릿이다.
- unique_ptr은 동일한 메모리를 가리키는 두 개의 unique_ptr 인스턴스가 동시에 존재할 수 없도록 한다.
- shared_ptr은 여러 shared_ptr 인스턴스가 동일한 메모리를 가질 수 있는 스마트 포인터다.
- weak_ptr은 shared_ptr의 중요한 동반자이며, 스마트 포인터가 순환 참조와 같은 문제에 대처하는데 큰 도움을 준다.
'C++ > 게임 개발자를 위한 C++ 문법' 카테고리의 다른 글
범위 기반 for 문(ranged-based for statement) (0) | 2025.01.07 |
---|---|
디자인 패턴(Design Pattern) : 생성(Creational) 패턴 (0) | 2025.01.07 |
선형 탐색과 이진 탐색 (0) | 2025.01.02 |
얕은 복사(Shallow Copy) VS 깊은 복사(Deep Copy) (0) | 2024.12.30 |
배열 자체의 정적 선언과 요소의 개별적 동적 할당 (0) | 2024.12.26 |