스마트 포인터는 말 그대로 똑똑한 포인터라고 할 수 있다.
기존 c++ 포인터는 프로그래머가 할당과 해제를 모두 신경써줘야 했다.
이로 인해서 성능상의 장점을 가져올 수 있었지만, 프로그래머들이 메모리 관리로 고생을 하게되는 단점도 존재했다.
스마트 포인터의 도입을 통해 앞서 언급한 단점을 해결할 수 있다.
c++에서 스마트포인터는 c++11에서 추가된 unique_ptr,shared_ptr,weak_ptr가 있다.
이 3가지 스마트 포인터는 헤더파일 memory를 include하면 사용할 수 있다.
#include <memory>
unique_ptr은 포인터변수가 가리키는 객체가 오직 그 포인터변수만 가리킬 수 있도록 보장해주는 포인터이다. move(unique_ptr)함수를 통해서 소유권을 이전할 수도 있다.
unique_ptr<int> uniquePtr1 = make_unique<int>(5);//생성
unique_ptr<int> uniquePtr2 = move(uniquePtr1);//uniquePtr2에게 소유권 전달
int* ptr1 = new int(5);
unique_ptr<int> uniquePtr1 = make_unique<int>(5);//unique_ptr 초기화
cout << "원시 int 포인터 size: " << sizeof(ptr1) << "byte" << endl;//8byte
cout << "Unique_Ptr int 포인터 size: " << sizeof(uniquePtr1) << "byte" << endl;//8byte
unique_ptr변수는 기존 c++포인터 크기와 차이가 있나 궁금해봐서 위 코드를 실행해봤다. 결과는 'unique_ptr크기 == c++원시 포인터 크기' 였다.
clock_t start, end;
int* ptr1 = new int(5);
int* ptrs[100000];
start = clock();
for (int i = 0; i < 100000; i++)
{
if (i == 0)
ptrs[0] = ptr1;
else
ptrs[i] = ptrs[i - 1];
}
end = clock();
cout << "원시 포인터 처리 시간 : " << end-start << "ms" << endl;//약 1ms
unique_ptr<int> uniquePtr1 = make_unique<int>(5);
unique_ptr<int> uniquePtrs[100000];
start = clock();
for (int i = 0; i < 100000; i++)
{
if (i == 0)
uniquePtrs[i] = move(uniquePtr1);
else
uniquePtrs[i] = move(uniquePtrs[i - 1]);
}
end = clock();
cout << "unique_ptr 처리 시간 : " << end-start << "ms" << endl;//약 10ms
unique_ptr의 성능을 알아보기 위해 극단적인 실험상황을 만들어 봤다. 100000개의 원시포인터,unique_ptr변수를 선언하고 포인터변수 100000개에 순차적으로 힙 영역에 할당한 객체를 가리키도록 하는 상황이다.
unique_ptr의 소유권을 이전하는 함수인 move()함수의 처리시간을 알아보기 위해 이러한 실험을 진행했다. 결론은 unique_ptr은 원시포인터보다 10배의 성능저하가 있다는 것이다.
10배의 성능저하가 있음에도 불구하고 unique_ptr은 유용한가?
대답은 Yes다. move()를 사용하지않고 소유권을 계속 가지고 있는 변수에 사용한다면? 성능저하없이 자동관리되는 포인터변수를 사용할 수 있게된다.
c++11은 객체를 포인팅하는 변수의 개수를 세서 포인팅하는 변수의 개수가 0이 될때 객체의 메모리를 해제하는 기능을 제공하기 시작했는데, 이때 사용되는 포인터 변수 형식이 shared_ptr이다.
shared_ptr<int> sharedPtr1 = make_shared<int>(5);//shared_ptr 초기화
위와 같은 형식으로 shared_ptr변수를 선언하면 힙 영역에 int객체, int객체에 대한 control block이 할당되고 shared_ptr변수는 int객체,int객체에 대한 control block을 포인팅 하게 된다. control block에는 해당 객체가 shared_ptr에 의해 참조된 횟수가 저장되는데, 참조 횟수가 0이 되면 해당 객체는 메모리에서 할당 해제된다.
shared_ptr과 control block의 관계는 위 그림을 참고하면 이해할 수 있다.
shared_ptr<int> sharedPtr1 = make_shared<int>(5);//shared_ptr 초기화
cout << "원시 int 포인터 size: " << sizeof(ptr1) << "byte" << endl;//8byte
cout << "Shared_Ptr int 포인터 size: " << sizeof(sharedPtr1) << "byte" << endl;//16byte
shared_ptr의 변수 크기는 일반 포인터의 2배인 16바이트이다. 그 이유는 일반 포인터와 달리 control block에 대한 포인팅 정보도 가지고 있어야 하기 때문이다.
clock_t start, end;
int* ptr1 = new int(5);
int* ptrs[100000];
start = clock();
for (int i = 0; i < 100000; i++)
{
if (i == 0)
ptrs[0] = ptr1;
else
ptrs[i] = ptrs[i - 1];
}
end = clock();
cout << "원시 포인터 처리 시간 : " << end - start << "ms" << endl;//약 0ms
shared_ptr<int> sharedPtr1 = make_shared<int>(5);
shared_ptr<int> sharedPtrs[100000];
start = clock();
for (int i = 0; i < 100000; i++)
{
if (i == 0)
sharedPtrs[i] = sharedPtr1;
else
sharedPtrs[i] = sharedPtrs[i - 1];
}
end = clock();
cout << "shared_ptr 처리 시간 : " << end - start << "ms" << endl;//약 10ms
shared_ptr의 성능을 알아보기 위해 위와 같은 실험을 진행했다.
100000개의 일반 포인터와 shared_ptr이 있을때 똑같이 하나의 객체를 포인팅하게 만드는 연산을 시켰다. 결과는 shared_ptr이 일반포인터 대비 10배 느렸다. shared_ptr은 포인터를 할당할때 control block에 접근해 ref count를 늘리는 연산이 추가되기 때문에 이러한 성능저하가 발생한다고 할 수 있다.
shared_ptr을 사용할때 주의해야할 점이 있다. Circular Reference관계로 클래스를 디자인했을때 객체의 메모리가 프로그램 종료시까지 할당되는 문제가 발생할 수 있다. 다음 코드는 Circular Reference이슈를 재현한 코드다.
CircularReference.h
#pragma once #include <memory> using namespace std; class Oak; class Elf; class Oak { public: share_ptr<Elf> friendElf; void MakeFriend(shared_ptr<Elf>& elf); private: int a[100000000]; }; class Elf { public: shared_ptr<Oak> friendOak; void MakeFriend(shared_ptr<Oak>& oak); private: int a[100000000]; };
CircularReference.cpp
#include "CircularReference.h" void Oak::MakeFriend(shared_ptr<Elf>& elf) { this->friendElf = elf; } void Elf::MakeFriend(shared_ptr<Oak>& oak) { this->friendOak = oak; }
main
#include "CircularReference.h" shared_ptr<Elf> elf = make_shared<Elf>(); shared_ptr<Oak> oak = make_shared<Oak>(); cout << "elf객체 참조 카운트: " << elf.use_count() << endl;//1 cout << "oak객체 참조 카운트: " << oak.use_count() << endl;//1 elf->MakeFriend(oak); oak->MakeFriend(elf); cout << "elf객체 참조 카운트: " << elf.use_count() << endl;//2 cout << "oak객체 참조 카운트: " << oak.use_count() << endl;//2 /*case 1. Circular Reference문제 발생한 경우 Sleep(1000 * 1000);//메모리 프로파일러상 841mb 사용중 elf.reset(); oak.reset(); Sleep(1000 * 1000);//메모리 프로파일러상 841mb 사용중(Circular Reference로 인한 프로그램 종료시까지 해제되지 않는 메모리영역 발생) */ /*case 2. 정상 종료한 경우 Sleep(1000 * 1000);//메모리 프로파일러상 841mb 사용중 elf->friendOak.reset(); elf.reset(); oak->friendElf.reset(); oak.reset(); Sleep(1000 * 1000);//메모리 프로파일러상 0mb 사용중
Circular Reference상황을 구현하기 위해서 오크,엘프 클래스가 서로를 포인팅하는 클래스를 선언했다. int 변수를 10만개 선언한 이유는 비주얼 스튜디오 메모리 프로파일링 기능으로 메모리 누수를 확인할때 확실히 확인을 하기 위해서이다.
case1에서는 reset()함수를 통해서 오크와 엘프의 메모리 해제를 시도했지만 주석으로 알 수 있듯이 메모리가 누수되는 결과를 보여준다.
case2에서는 엘프의 friendOak, 오크의 friendElf변수에 대해서도 reset()함수를 호출해서 메모리 해제를 시도해서 메모리 관리를 성공한 결과를 보여준다.
이 실험을 통해서 reset()함수는 단지 ref count를 1줄여주는 역할을 한다는 것을 알 수 있다. case1에서 reset()을 해도 메모리가 해제되지 않은 이유는 elf, oak->friendElf에 의해 ref count가 2였기 때문이다.
shared_ptr을 잘못 사용하면 case1과 같은 메모리 누수 문제를 겪게 된다.이 문제를 해결하기 위해 weak_ptr개념이 존재하는데, weak_ptr을 설명할때 이어서 설명하겠다.
shared_ptr은 크기부터 일반 포인터의 2배인데다가 포인팅할때 10배의 성능저하가 있다. 때문에, shared_ptr을 사용하면 편의성을 얻는 대신 성능을 잃는다는 생각으로 사용여부를 결정해야 한다.
shared_ptr을 써도 성능적으로 여유가 있다면 개발의 편의성을 위해 shared_ptr을 쓰면 좋으나
shared_ptr을 썼을때 성능적으로 부하가 발생해서 목표 성능수치를 맞추지 못한다면 shared_ptr을 버려야 할것이다.
weak_ptr은 shared_ptr의 Circular Reference문제를 보완하기 위해 존재하는 포인터이다. shared_ptr은 객체를 포인팅하게 될 때 control block의 ref count를 1 늘리지만, weak_ptr은 객체를 포인팅해도 control block의 ref count를 늘리지 않는다.
즉, weak_ptr로 선언한 변수는 포인팅한 객체의 생존주기에 영향을 주지 않게 된다.
int* ptr1 = new int(5);
shared_ptr<int> sharedPtr1 = make_shared<int>(5);//shared_ptr 초기화
weak_ptr<int> weakPtr1 = sharedPtr1;//shared_ptr을 가리키는 weak_ptr
cout << "원시 int 포인터 size: " << sizeof(ptr1) << "byte" << endl;//8byte
cout << "Weak_Ptr int 포인터 size: " << sizeof(weakPtr1) << "byte" << endl;//16byte
weak_ptr은 shared_ptr과 동일하게 16바이트의 크기이다. shared_ptr과 마찬가지로 control block을 포인팅하기 때문이다. weak_ptr변수가 포인팅을 하게되면 control block의 weak counter값이 1 증가하게 된다.
여기서, 왜 weak counter를 사용하는지 의문이 들 수 있다.
어차피 객체의 수명을 결정하는 것은 shared_ptr의 ref count아닌가?
하지만, 구글링을 해보니 필요성을 알게 되었다.
스택 오버플로우 링크
그 이유는 shared_ptr ref count가 0이 되면 weak_ptr이 가리키는 객체는 죽어버리는데, weak counter도 존재하지 않는다면? control block또한 죽어있을 것이다. 이 weak_ptr 포인터 변수로 죽어버린 객체에 접근한다면 프로그램은 런타임 에러로 뻗어버린다. weak counter가 존재한다면? control block은 shared_ptr ref count가 0이지만 weak counter는 0이 아니기 때문에 메모리에 남아있고, weak_ptr 포인터 변수로 죽어버린 객체에 접근하기 전에 살아있는 control block에 접근해서 '아 내가 포인팅하는 객체는 죽은 객체구나'라고 인지한 후 런타임 에러를 예외처리하는 것이 가능하다.
clock_t start, end;
int* ptr1 = new int(5);
int* ptrs[100000];
start = clock();
for (int i = 0; i < 100000; i++)
{
if (i == 0)
ptrs[0] = ptr1;
else
ptrs[i] = ptrs[i - 1];
}
end = clock();
cout << "원시 포인터 처리 시간 : " << end - start << "ms" << endl;//약 0ms
shared_ptr<int> sharedPtr1 = make_shared<int>(5);
weak_ptr<int> weakPtrs[100000];
start = clock();
for (int i = 0; i < 100000; i++)
{
if (i == 0)
weakPtrs[i] = sharedPtr1;
else
weakPtrs[i] = weakPtrs[i - 1];
}
end = clock();
cout << "weak_ptr 처리 시간 : " << end - start << "ms" << endl;//약 10ms
weak_ptr은 shared_ptr과 동일한 성능을 가지고 있다.
CircularReference.h
#pragma once #include <memory> using namespace std; class Oak; class Elf; class Oak { public: //share_ptr<Elf> friendElf; weak_ptr<Elf> friendElf; void MakeFriend(shared_ptr<Elf>& elf); private: int a[100000000]; }; class Elf { public: //shared_ptr<Oak> friendOak; weak_ptr<Oak> friendOak; void MakeFriend(shared_ptr<Oak>& oak); private: int a[100000000]; };
CircularReference.cpp
#include "CircularReference.h" void Oak::MakeFriend(shared_ptr<Elf>& elf) { this->friendElf = elf; } void Elf::MakeFriend(shared_ptr<Oak>& oak) { this->friendOak = oak; }
main
shared_ptr<Elf> elf = make_shared<Elf>(); shared_ptr<Oak> oak = make_shared<Oak>(); cout << "elf객체 참조 카운트: " << elf.use_count() << endl;//1 cout << "oak객체 참조 카운트: " << oak.use_count() << endl;//1 elf->MakeFriend(oak); oak->MakeFriend(elf); cout << "elf객체 참조 카운트: " << elf.use_count() << endl;//1 cout << "oak객체 참조 카운트: " << oak.use_count() << endl;//1 Sleep(1000 * 1000);//메모리 프로파일러상 841mb 사용중 elf.reset(); oak.reset(); Sleep(1000 * 1000);//메모리 프로파일러상 0mb 사용중(Circular Reference 문제를 weak_ptr사용으로 해결
shared_ptr사용시 발생할 수 있는 CircularReference문제를 weak_ptr사용으로 해결한 코드이다. elf,oak클래스의 멤버변수를 shared_ptr에서 weak_ptr로 바꿔주니 메모리 누수 문제가 없어진 것을 확인 할 수 있다.
단독으로 사용할 수는 없고 shared_ptr과 같이 사용하게 되는데,
CircularReference문제를 일으킬 수 있다고 판단될 때 사용하거나
의도적으로 일부 shared_ptr에 의해서만 객체의 생존주기를 관리하고 싶을때 사용하면 좋을 것이다.