- #include <iostream>
- using namespace std;
- class Widget{
- public:
- Widget(){cout<<" Widget() "<<endl;}
- ~Widget() {cout<<" ~Widget() " <<endl;}
- void dosomething() {}
- };
- void main()
- {
- Widget* p = new Widget;
- //자원을 이용하는 코드...
- delete p;
- }
이 책에서는 자원을 동적으로 할당된 객체에 대해서만 한정해 설명을 하고 있는데, 예를 들어 Widget* p = new Widget(); 와 같은 코드에서 p 자체를 자원이라고 부르고 있습니다. C++은 delete로 자원을 반납하는 특성을 가집니다. 동적으로 할당된 자원을 delete로 반납하지 않으면 메모리 누수가 일어나죠. 애초에 작성할 때 new로 할당을 하고 이어서 delete를 해주고 그 사이에 코드를 작성하게 되는데, 이 사이에 들어가는 코드가 return 문을 가지고 있는 경우에 delete문을 호출해 주지 않기 때문에 마지막에서 delete를 해준다고 해도 중간에 함수가 끝나서 메모리 누수가 발생합니다. 만약 이런 코드가 루프 문에 들어가 있다고 해봅시다.
- for(..)
- {
- Widget* p = new Widget;
- break;
- continue;
- delete p;
- }
루프문은 break이나 continue를 이용해서 루프를 제어하게 되는데 이런 제어 코드 때문에 위 경우와 마찬가지로 delete가 실행이 되지 않고 메모리 누수가 일어 날 수 있습니다. 만약 이런 상황을 막기 위해서는 제어 코드 이전에 delete를 해주면 되지만, 이런 코드 들이 여러개 존재하고 이런 일들을 일일이 해준다고 한다면 번거롭기도 하고, 자원관리에 어려움이 있습니다. 이런 자원을 사용자가 직접 할당 받고 반납하는 과정을 다 염두에 두고 코딩을 해야 하는 것이 프로그래머에게 부담이 되고 문제가 됩니다. 그래서 이펙티브 C++에서는 자원을 사용자가 직접 해제 하지 말고, 자원 관리 객체를 이용해 그 자원관리 객체가 소멸할때 소멸자에서 자원을 소멸하도록 이용하라고 되어 있습니다.
auto_ptr |
자원관리 객체는 주로 스마트 포인터를 이용하는데, 스마트 포인터 중에 첫번째로는 Auto Pointer가 있습니다. 이것은 memory 헤더 안에 선언이 되어 있어 사용할때는 헤더를 선언해주고 사용해줘야 합니다.
- #include <iostream>
- #include <memory>
- using namespace std;
- class Widget{
- public:
- Widget(){cout<<" Widget() "<<endl;}
- ~Widget() {cout<<" ~Widget() " <<endl;}
- void do() {}
- };
- void main()
- {
- std::auto_ptr<Widget> p(new Widget);
- }
위와 같이 auto_ptr을 사용하면 예전처럼 동적 할당후 자원 반납을 해주지 않아도 자기 객체가 사라질때 소멸자에서 자원의 delete를 적용해줘서 결과적으로 delete를 안써줘도 되게 만들어져 있습니다. 여기 코드에서는 할당받은 자원을 자원 관리 객체 p에다가 넘기고 있는데, 자원관리 객체의 초기화 코드에 할당받은 자원을 넘기는 것을 "자원획득 초기화(RAII)" 라고 합니다.즉, 자원관리 객체 p가 있고 p의 초기화로 자원을 넘겨서 그 p가 소멸이 될때 그때 delete를 해주게 되는 방식이죠.
이제 return과 같은 제어코드나 다른 상황에서도 이 p는 지역변수 와 똑같이 동작을 해서 루프를 빠져나와 함수를 빠져나갈때 delete를 호출해 주기 때문에 자원누수를 막을 수 있습니다. 그러니까 자원해제를 사용자가 하는게 아니라 자원 관리 객체 라는 객체가 관리를 해주니까 누수에 대한 걱정을 하지 않아도 되는 것입니다.
그런데 auto_ptr에는 아래와 같은 문제가 일어날 수 있을 것이라고 생각할 수 있을것입니다.
- #include <iostream>
- #include <memory>
- using namespace std;
- class Widget{
- public:
- Widget(){cout<<" Widget() "<<endl;}
- ~Widget() {cout<<" ~Widget() " <<endl;}
- void do() {}
- };
- void main()
- {
- std::auto_ptr<Widget> p(new Widget);
- std::auto_ptr<Widget> p2(p); //복사
- }
위와 같이 auto_ptr을 복사를 하게 되면 똑같은 자원에 대해 두 자원관리 객체가 생기게 되는 꼴이 되는데, 이놈의 소멸자가 자원에 대한 delete를 해주게 되니까 같은 자원을 두번 해제하게 되는 일이 발생할 수도 있을 거라 예상이 될 것입니다. 그래서 auto_ptr은 그런것을 막기 위해서 소유권을 넘긴다는 방식을 취하고 있다.
소유권을 넘긴다? 소유권을 넘긴다는 의미는 기존의 자원은 null로 바꿔 버리고 새로 복사된 객체(이전에 있던 자원을) 유지하는 방식으로 동작을 합니다.
- void main()
- {
- std::auto_ptr<Widget> p(new Widget); //Null
- std::auto_ptr<Widget> p2(p); //Widget
- }
C++에서도 'delete NULL' 해도 정상적으로 작동을 하므로 표준에 어긋난 것은 아닙니다. 여기에서 p2가 먼저 소멸 되는데, 그때 자원이 반납되고, p는 null이기 때문에 어떤 동작도 안하게 되므로, 자원 관리 객체의 복사 문제 해결하고 있습니다. 물론 복사대입 연산자도 이런 처리가 되어 있습니다. 하지만 문제가 있습니다.
- #include <iostream>
- #include <memory>
- using namespace std;
- class Widget{
- public:
- Widget(){cout<<" Widget() "<<endl;}
- ~Widget() {cout<<" ~Widget() " <<endl;}
- void dosomething(){}
- };
- void main()
- {
- std::auto_ptr<Widget> p(new Widget);
- std::auto_ptr<Widget> p2(p); //복사
- p2->dosomething(); //take breakpoint
- p->dosomething();
- }
위 소스 코드를 실행해보면, p2는 지금 자원을 가지고 있기 때문에 정상적으로 호출되는데 p는 p2의 자원을 넘겨주고 자신은 null로 바뀌었기 때문에 null에 대해서 멤버 함수를 호출하는 동작을 취하면 프로그램이 이 부분에서 뻗게 되는 것입니다. 기존 동작의 포인터와 다른 복사 동작을 가지고 있는것이 오토 포인터의 특징이라고 할 수 있습니다.
하지만 이런 auto_ptr은 특성 때문에 STL의 컨테이너나 같은 곳에 같이 사용하면 문제가 발생하는 경우가 생길 수 있습니다. 그래서 우리가 일반적인 포인터를 사용할때처럼, 복사는 자유로우면서, 소멸 또한 관리가 되는 또 다른 자원관리 객체의 필요성이 대두 됩니다. 그래서 나타난것이 shared_ptr입니다.
shared_ptr |
: shared_ptr은 참조 카운팅 방식 스마트 포인터(reference-counting smart pointer) 중의 하나로서, 원래는 Boost Library에 있다가 C++의 새로운 표준인 tr1이라는 이름공간에 추가되었습니다.
- #include <iostream>
- #include <memory>
- using namespace std;
- class Widget{
- public:
- Widget(){cout<<" Widget() "<<endl;}
- ~Widget() {cout<<" ~Widget() " <<endl;}
- void dosomething(){}
- };
- void main()
- {
- std::tr1::shared_ptr<Widget> p (new Widget); //counting : 1
- std::tr1::shared_ptr<Widget> p2(p); //counting : 2
- p2->dosomething();
- p->dosomething();
- }
shared_ptr은 내부적으로 카운팅을 유지 해서(참조 갯수를 유지 해서) 자원의 참조 갯수를 셉니다. 처음 생성했을 때는 한개가 되고, 그 다음에는 두개가 되는 식으로 말이죠. shared_ptr는 참조 갯수만 관리 하기 때문에 위 소스코드가 무리없이 동작 되는 것을 알 수 있습니다. 그리고 shared_ptr도 마찬가지로 소멸시 delete를 호출해주는데 이 점때문에, 복사동작이 많이 요구되고 동시에 자원이 관리 되어야 하는 상황에서는 auto_ptr 보다 shared_ptr이 더 적합합니다. 그런데 문제는 자원이 동적으로 하나만 생성된것이 아니라, 아래와 같이 동적 배열로 생성된 경우는 shared_ptr은 스스로 판단을 할 수 없다는 것이 문제점 입니다.
- #include <iostream>
- #include <memory>
- using namespace std;
- class Widget{
- public:
- Widget(){cout<<" Widget() "<<endl;}
- ~Widget() {cout<<" ~Widget() " <<endl;}
- void dosomething(){}
- };
- void main()
- {
- std::tr1::shared_ptr<Widget> p (new Widget[4]);
- std::tr1::shared_ptr<Widget> p2(p);
- p2->dosomething();
- p->dosomething();
- }
auto_ptr과 마찬가지로 shared_ptr도 delete 를 호출합니다. 하지만 위와 같이 동적 배열로 생성된 경우에는 delete[]로 호출해서 소멸시켜야 하는데, 그냥 delete만 호출 하는 할당과 반납의 형태가 다르다는 문제가 생기는 것입니다. 그래서 이런 경우는 STL의 벡터(vector) 같은 컨테이너와 같이 조합을 해서 이용하는 것을 추천합니다.
- #include <iostream>
- #include <memory>
- #include <vector>
- using namespace std;
- class Widget{
- public:
- Widget(){cout<<" Widget() "<<endl;}
- ~Widget() {cout<<" ~Widget() " <<endl;}
- void dosomething(){}
- };
- void main()
- {
- std::vector<std::tr1::shared_ptr<Widget>> p(4);
- std::tr1::shared_ptr<Widget> p2(p);
- p2->dosomething();
- p->dosomething();
- }
자원 관리 객체는 하나의 자원만 받고 그 자원관리 객체를 여러개를 컨테이너로 관리 하면 자원이 여러개인 경우도 쉽게 관리를 할 수 있게 되는 것입니다.
shared_ptr 삭제자 |
: shared_ptr은 특별하게 삭제자를 지정해 줄 수 있습니다. shared_ptr의 기본 삭제 동작은 delete를 호출하는 것인데, 이런 동작을 바꿔서
delete 배열 연산자를 호출 할수 있도록 바꿔 줄 수도 있습니다.
- #include <iostream>
- #include <memory>
- using namespace std;
- class Widget{
- public:
- Widget(){cout<<" Widget() "<<endl;}
- ~Widget() {cout<<" ~Widget() " <<endl;}
- void dosomething(){}
- };
- struct arrayDeleter
- {
- template<typename T>
- void operator()(T* p)
- {
- delete [] p;
- }
- };
- void main()
- {
- std::tr1::shared_ptr<Widget> p(new Widget[4], arrayDeleter());
- }
위와 같이 삭제자를 바꿀 수가 있습니다. 여기에서는 획득시 초기화를 이용해 자원을 넘기는데, 여기에 추가적인 인수를 더 받도록 되어 있습니다. 여기에 위에서 만든 arrayDeleter를 불러줘 삭제의 동작을 바꿔 주는 것입니다.
이렇게 삭제자 중에서는 우리는 가끔 빈삭제자를 이용해야 할 때가 있는데, 빈삭제자(Empty Deleter)는 삭제 동작을 아무런 동작을 취하지 않는 것을 말합니다.
- #include <iostream>
- #include <memory>
- using namespace std;
- struct emptyDeleter
- {
- template<typename T>
- void operator()(T* p)
- {
- }
- };
- class Widget{
- public:
- Widget(){cout<<" Widget() "<<endl;}
- ~Widget() {cout<<" ~Widget() " <<endl;}
- std::tr1::shared_ptr<Widget> getThis() {return std::tr1::shared_ptr<Widget>(this);}
- };
- void main()
- {
- std::tr1::shared_ptr<Widget> p(new Widget[4], emptyDeleter());
- }
지금과 같은 상황에서 빈삭제자를 쓴다면 메모리 누수가 생길텐데, 이 빈삭제자의 활용법은 자기 자신의 객체를 외부로 반환하는 함수가 있다고 할때, std::tr1::shared_ptr<Widget> getThis() {return std::tr1::shared_ptr<Widget>(this);}
위와 같이 작성하면, this가 자원관리 객체로 넘어가면서 반환하게 되는데 shared_ptr의 기본 동작이 delete를 호출해주기 때문에, 지금같은 경우 this를 delete하면 문제가 생길것입니다. 그래서 여기서 빈삭제자를 넣어 이런 삭제를 막는 것입니다.
std::tr1::shared_ptr<Widget> getThis() {return std::tr1::shared_ptr<Widget>(this, emptyDeleter() );}
끝으로 shared_ptr 같이 타입이름이 너무 기니까 typedef를 쓰게 되는데 실제로 많이 쓰는 방법은 어떤 타입에 대한 shared_ptr 포인터 타입을 만들기 위해 위에서 전방 선언만 해주고 (컴파일러에게 이름을 알려준다.) 그 이름을 shared_ptr 포인터에 넘겨 새로운 타입을 만들어서 사용하면 이런 선언에 대한 이름길이의 부담을 줄일 수 있습니다.
- #include <memory>
- #include <iostream>
- using namespace std;
- class Widget;
- typedef std::tr1::shared_ptr<Widget> Widget_ptr;
- class Widget{
- public:
- Widget(){cout<<" Widget() "<<endl;}
- ~Widget() {cout<<" ~Widget() " <<endl;}
- std::tr1::shared_ptr<Widget> getThis() {return std::tr1::shared_ptr<Widget>(this);}
- };
- void main()
- {
- Widget_ptr p(new Widget);
- }
지금까지 자원관리에 대해 auto_ptr과 shared_ptr에 대해서 알아봤는데요, 사용자는 자원을 언제든지 놓칠 수 있기 때문에, delete를 언제나 챙길 수 없습니다. 그래서 이것을 자원관리 객체라는 또 다른 객체에 넘겨서 관리를 하자는게 이번 항목의 목적이라고 할 수 있습니다.
* 일반적으로 널리 쓰이는 RAII 클래스는 tr1::shared_ptr 그리고 auto_ptr입니다. 이 둘 가운데 tr1::shared_ptr이 복사 시의 동작이 직관적이기 때문에 대개 더 좋습니다. 반면, auto_ptr은 복사되는 객체(원본 객체)를 null로 만들어 버립니다.
댓글 없음:
댓글 쓰기