일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | 3 | 4 | |||
5 | 6 | 7 | 8 | 9 | 10 | 11 |
12 | 13 | 14 | 15 | 16 | 17 | 18 |
19 | 20 | 21 | 22 | 23 | 24 | 25 |
26 | 27 | 28 | 29 | 30 | 31 |
Tags
- Smart Pointer
- 루아
- 게임
- exception
- 스마트 포인터
- 다형성
- 메타테이블
- reference
- 오블완
- 티스토리챌린지
- more effective c++
- 암시적 변환
- 반복자
- 예외
- lua
- 영화 리뷰
- 비교 함수 객체
- effective stl
- Effective c++
- implicit conversion
- virtual function
- 상속
- resource management class
- c++
- 참조자
- 영화
- UE4
- 함수 객체
- 언리얼
- operator new
Archives
- Today
- Total
스토리텔링 개발자
[More Effective C++] 28. 스마트 포인터 본문
728x90
항목 28. 스마트 포인터(Smart Pointer)
스마트 포인터를 사용하는 이유
- 생성과 소멸 작업을 조절할 수 있다.
- 스마트 포인터가 생성되고 소멸되는 시기를 사용자가 결정할 수 있다.
- 초기값을 스마트 포인터가 컨트롤하므로, 쓰레기값 문제가 없다.
- 객체를 알아서 소멸시켜주므로 리소스 누수가 없다.
- 복사와 대입 동작을 조절할 수 있다.
- 스마트 포인터가 복사되고 대입될 때의 일을 사용자가 결정할 수 있다.
- 깊은 복사와 얕은 복사를 원하는 대로 정의할 수 있다.
- 혹은 복사 대입을 전혀 사용하지 못하도록 막을 수도 있다.
- 역참조 동작을 조절할 수 있다.
- 스마트 포인터가 가리키는 객체를 가져오려고 할 때 일어나는 일을 결정할 수 있다.
- 지연 방식 데이터 / 명령어 가져오기(fetching)(항목 17 참조)
스마트 포인터의 생김새
- 범용성을 위해 템플릿으로 구현되어 있다.
template<typename T>
class SmartPtr
{
public:
SmartPtr(T* realPtr = 0); // 포인터에 쓰레기값이 들어가지 않도록
SmartPtr(const SmartPtr& rhs); // 복사 생성자
~SmartPtr(); // 소멸자
SmartPtr& operator=(const SmartPtr& rhs); // 대입 연산자
T* operator->() const; // 역참조한 포인터 멤버
T& operator*() const; // 역참조
private:
T* pointee; // 스마트 포인터가 가리키는 실제 객체의 주소
};
스마트 포인터의 생성, 대입, 소멸
- 생성
- 가리킬 객체를 하나 준비하고, 그 객체의 포인터를 스마트 포인터 내부에서 가리키도록 한다.
- 가리킬 객체가 없으면 내부 포인터를 널로 처리하거나, 에러를 알린다.
- 대입과 소멸
- 복사 생성자, 대입 연산자, 소멸자 구현은 소유권 때문에 꽤 복잡하다.
- 자신이 가리키는 객체는 소멸 시에 삭제해줘야 하지만...
- 만약 복사나 대입이 되었다면 삭제해도 안전할까?
auto_ptr<TreeNode> ptn1(new TreeNode);
// 복사 생성자
auto_ptr<TreeNode> ptn2 = ptn1;
// 대입 연산자
auto_ptr<TreeNode> ptn3;
ptn3 = ptn2;
// 그리고 ptn1이 범위를 벗어나면서 소멸자가 호출되면?
// ptn2, ptn3도 같은 포인터를 가리키므로 댕글링 포인터가 발생하지 않을까?
- 해결법을 강구해보자.
- 복사시, new를 사용하여 가리키는 객체를 깊은 복사 해버린다?
- 불필요하게 수행 성능의 저하가 발생할 수 있다.
- 스마트 포인터의 타입과 실제 객체의 타입이 일치하지 않을 수 있다.
- T 타입의 파생 클래스라도 문제없이 가질 수 있기 때문이다.
- 그렇다면 잘못된 클래스(기본 클래스)로 파생 클래스가 깊은 복사가 될 여지가 있다.
- 복사나 대입을 못하게 한다?
- 너무 큰 불편함을 야기한다.
- 복사 혹은 대입될 때 소유 관계를 옮긴다?
- auto_ptr의 기능이 바로 그것이다.
- 하지만 이 역시 다양한 문제를 야기하기 때문에, 모던 C++에서는 이동으로만 소유권을 옮길 수 있는 unique_ptr을 사용한다.
- auto_ptr은 C++17에서 완전히 제거되었다.
- 복사시, new를 사용하여 가리키는 객체를 깊은 복사 해버린다?
auto_ptr(C++17에서 제거됨)
- 복사 혹은 대입될 때 소유 관계를 옮기는 스마트 포인터
template<typename T>
class auto_ptr
{
...
public:
auto_ptr(auto_ptr<T>& rhs); // 복사 생성자
auto_ptr<T>& operator=(auto_ptr<T>& rhs); // 대입 연산자
...
};
template<typename T>
auto_ptr<T>::auto_ptr(auto_ptr<T>& rhs)
{
pointee = rhs.pointee; // 본 객체를 옮긴다.
rhs.pointee = nullptr; // 기존의 본 객체는 invalid
}
template<typename T>
auto_ptr<T>& auto_ptr<T>::operator=(auto_ptr<T>& rhs)
{
if(this == &rhs) // 자기 자신에게 대입 시에는 아무 일도 없다.
return *this;
delete pointee; // 자신이 가진 객체를 삭제한다.
pointee = rhs.pointee; // 소유권을 옮긴다.
rhs.pointee = nullptr; // 기존의 본 객체는 invalid
return *this;
}
- 이 경우 값으로 함수의 매개변수에 auto_ptr을 넘기게 되면 문제가 된다.
// 재앙을 일으키는 함수
void printTreeNode(ostream& s, auto_ptr<TreeNode> p) { s << *p; }
int main()
{
auto_ptr<TreeNode> ptn(new TreeNode);
// auto_ptr이 전달된다!!
// 즉, 복사가 발생한다.(복사 생성자)
printTreeNode(cout, ptn);
// ptn은 nullptr을 참조하게 되며,
// 복사된 매개변수는 유효범위를 벗어나며 삭제되고 만다..
}
- 참조자로 전달해서 위기를 모면하면 된다.
void printTreeNode(ostream& s, const auto_ptr<TreeNode>& p)
{
s << *p;
}
- 추가로 확인할 만한 사항
- auto_ptr의 복사 생성자, 대입 연산자의 매개변수 auto_ptr은 const가 아니다.
- 매개변수의 상태가 바뀌는지 아닌지, 인터페이스만으로 알아볼 수 있다.
- 만약 복사 생성자, 대입 연산자가 const 매개변수만 받아야 한다고 C++이 제약을 가했으면 온갖 트릭을 사용해야 했을 것이다..
- auto_ptr의 소멸자 구현
-
template<typename T> SmartPtr<T>::~SmartPtr() { if(*this가 *pointee를 소유하면) delete pointee; }
- 여기서 점검부(if문)은 auto_ptr에서는 필요가 없다.
- 반드시 객체를 소유하고 있다고 봐도 무방하기 때문이다.
-
- auto_ptr의 복사 생성자, 대입 연산자의 매개변수 auto_ptr은 const가 아니다.
역참조(Dereferencing) 연산자 구현
operator*
template<typename T>
T& SmartPtr<T>::operator*() const
{
스마트 포인터 처리 수행;
// 예를 들면, 지연 방식(항목 17 참조)으로 가져오게 되어 있다면 객체 불러오기 처리
return *pointee;
}
- 반환값은 참조자로 구현되어 있다. 값으로는 절대 반환하지 말자.
- 왜냐하면 pointee는 반드시 T라고 할 수 없기 때문이다.
- T의 하위 클래스일 수도 있다. 그 경우 슬라이스 문제가 발생한다. (항목 13 참조)
- 더군다나 당연하게도 값보다 참조자로 반환하는 것이 더 효율적이다.
- 왜냐하면 pointee는 반드시 T라고 할 수 없기 때문이다.
- nullptr에 대해 operator*를 호출하면 어떻게 될까?
- 원하는 대로 에러 처리를 하자.
- 널 포인터에 대해 역참조한 결과는 정의되지 않았기 때문이다.
역참조(Dereferencing) 연산자 구현
operator->
- operator*의 동작과 별반 다르지 않다.
- 하지만, 아래의 상황에서의 의미를 고찰해볼 필요가 있다.
void editTuple(DBPtr<Tuple>& pt)
{
LogEntry<Tuple> entry(*pt);
do
{
pt->displayEditDialog();
// 이는 다음과 같이 해석된다.
// (pt.operator->())->displayEditDialog();
// 즉,
// 1. operator->가 실행되고,
// 2. 멤버 지정 연산자(->)가 실행된다.
// 이는 멤버 지정 연산자(->)는 operator->의 반환값에 상관 없이 잘 동작해야 한다는 의미.
}
while (pt->isValid() == false);
}
- 보통 이런 식으로 구현된다.
template<typename T>
T* SmartPtr<T>::operator->() const
{
스마트 포인터 처리를 수행한다;
return pointee;
}
- 포인터가 리턴되므로, operator->를 호출한 곳에서 멤버 지정 연산자는 문제 없이 동작하게 된다.
스마트 포인터가 널(nullptr)인지 점검
- 위의 구현만으로는 스마트 포인터는 raw 포인터는 가능한, 아래와 같은 사용이 불가능하다.
SmaprtPtr<TreeNode> ptn;
if(ptn == nullptr) ... // 에러
if(ptn) ... // 에러
if(!ptn) ... // 에러
- 어떻게 해결해볼 방법이 있을까?
- 방법 1 : 암시적 타입변환 연산자를 구현하기
template<typename T>
class SmartPtr
{
public:
...
operator void*();
// 스마트 포인터가 널이면 nullptr 반환, 아니면 포인터 반환
...
};
SmartPtr<TreeNode> ptn;
if(ptn == nullptr) ... // 동작
if(ptn) ... // 동작
if(!ptn) ... // 동작
- 이 방법은 iostream 클래스가 제공하는 타입변환 방법과 유사하다.
ifstream inputFile("datafile.dat");
if(inputFile) ... // inputFile이 제대로 열렸으면 true 반환
- 이 방법의 단점은, 실패할 것 같은 호출에 대해서도 실패시키지 않는다는 점이다.(항목 5 참조)
SmartPtr<Apple> pa;
SmartPtr<Orange> po;
// 전혀 다른 두 개의 타입을 비교하는데..
if(pa == po) ... // 컴파일이 성공한다!!
// operator== 함수가 없더라도 문제 없는 코드이다.
// void* 포인터로 암시적 변환이 가능하기 때문이다.
- 방법 2 : operator! 연산자를 오버로딩하기
- 위의 방법에 비해 실수에 안전하고 그나마 가독성 있는 사용법을 제공하게 된다.
template<typename T>
class SmartPtr
{
public:
...
bool operator!() const; // 스마트 포인터가 널일 때만 true 반환
...
};
SmartPtr<TreeNode> ptn;
if(!ptn == false) ... // 성공
// 허나, 아래의 코드는 실패
if(ptn == nullptr) ... // 실패
if(ptn) ... // 실패
// 주의해야 하는 경우
SmartPtr<Apple> pa;
SmartPtr<Orange> po;
if(!pa == !po) ... // 안타깝게도 컴파일 성공
스마트 포인터를 raw 포인터로 변환하기
- 기존의 raw 포인터를 스마트 포인터로 바꿔버리고 싶다면, 이 변환이 필요해진다.
class Tuple { ... };
void normalize(Tuple* pt); // 다음과 같은 인터페이스가 있었다면
DBPtr<Tuple> pt; // 우리는 위 함수에 스마트 포인터를 넣고 싶다.
normalize(pt); // 스마트 포인터를 넣어보지만.. 컴파일 실패
// DBPtr<Tuple>에서 Tuple*로 변환하는 데 필요한 것이 아무것도 없다.
normalize(&*pt); // 못생겼지만, 컴파일은 성공
- 좀 더 우아하게 해결해보자.
- 스마트 포인터 템플릿에다가 raw 포인터로의 암시적 변환 연산자를 추가해준다.
template<typename T>
class DBPtr
{
public:
...
// T*로의 암시적 변환 추가
operator T*() { return pointee; }
...
};
DBPtr<Tuple> pt;
normalize(pt); // 컴파일 성공
// 이제 널 점검에 대한 고민도 사라진다.
if(pt == nullptr) ... // Tuple*로 변환되어 성공
if(pt) ... // Tuple*로 변환되어 성공
if(!pt) ... // Tuple*로 변환되어 성공
- 하지만 암시적 변환 비직관적인 문제들이 산재한다. (항목 5 참조)
- 더군다나 아래처럼 마구잡이로 사용할 여지가 있으므로 위험하다.
void processTuple(DBPtr<Tuple>& pt)
{
Tuple* rawTuplePtr = pt; // DBPtr<Tuple>을 Tuple*로 변환
rawTuplePtr을 써서 튜플 데이터를 바꾼다;
}
- 그리고 raw 포인터로의 암시적 변환이 모든 상황을 대처하진 않는다.
- 사용자 정의 타입 변환을 암시적으로 두 번 이상 하는 건 C++이 금지하고 있기 때문이다.
class TupleAccessors
{
public:
TupleAccessors(const Tuple* pt);
...
}
// 다음과 같은 함수가 있다면
TupleAccessors merge(const TupleAccessors& ta1, const TupleAccessors& ta2);
// 문제 없는 상황
Tuple* pt1;
Tuple* pt2;
marge(pt1, pt2);
// 하지만 스마트 포인터일 때는 문제가 생긴다.
DBPtr<Tuple> pt1;
DBPtr<Tuple> pt2;
marge(pt1, pt2); // 컴파일 에러. TupleAccessors로 바꿀 방법이 없다.
- 정말 크리티컬한 이슈도 남았다!
- 스마트 포인터에 대해 delete를 사용해버린다면?
DBPtr<Tuple> pt = new Tuple;
delete pt; // 암시적 변환으로 인해 컴파일이 성공해버린다!
- 이 경우, 해당 포인터는 두 번 삭제될 것이다.
- delete 문으로 인한 삭제
- DBPtr의 소멸자로 인한 삭제
- 두 번 삭제는 미정의 사항이므로 프로그램이 어떻게 동작할지 알 수 없다.
- 그러므로 정말 정말 필요하지 않다면 raw 포인터로의 암시적 변환을 지원하진 말자.
스마트 포인터와 상속 기반의 타입변환
- 기본적으로 스마트 포인터는 다형성 기반을 제공하지 않는다.
class MusicProduct
{
public:
MusicProduct(const string& title);
virtual void play() const = 0;
virtual void displayTitle() const = 0;
...
};
class Casssette : public MusicProduct
{
public:
Cassette(const string& title);
virtual void play() const;
virtual void displayTitle() const;
...
};
class CD : public MusicProduct
{
public:
CD(const string& title);
virtual void play() const;
virtual void displayTitle() const;
...
};
void displayAndPlay(const MusicProduct* pmp, int numTimes)
{
for(int i = 1 ; i <= numTimes ; ++i)
{
// 상속 계통을 사용한 가상 함수 호출
pmp->displayTitle();
pmp->play();
}
}
Cassette* funMusic = new Cassette("Alapalooza");
CD* nightmareMusic = new CD("Disco Hits of the 70s");
// 별반 문제 없는 다형성 사용
displayAndPlay(funMusic, 10);
displayAndPlay(nightmareMusic, 0);
// 근데 스마트 포인터를 사용하면 이 평범한 코드가 불가능하다.
// 스마트 포인터를 사용하게 인터페이스 수정
void displayAndPlay(const SmartPtr<MusicProduct>& pmp, int numTimes);
SmartPtr<Cassette> funMusic(new Cassette("Alapalooza"));
SmartPtr<CD> nightmareMusic(new CD("Disco Hits of the 70s"));
displayAndPlay(funMusic, 10); // 컴파일 에러!
displayAndPlay(nightmareMusic, 0); // 컴파일 에러!
- 다행스럽게도 이 제약을 벗어날 방법이 있다.
- 위에서 사용했던 암시적 변환 방법을 사용하는 것이다.
class SmartPtr<Cassette>
{
public:
operator SmartPtr<MusicProduct>() // SmartPtr<MusicProduct> 로의 암시적 변환
{
return SmartPtr<MusicProduct>(pointee);
}
...
private:
Cassette* pointee;
};
class SmartPtr<CD>
{
public:
operator SmartPtr<MusicProduct>() // SmartPtr<MusicProduct> 로의 암시적 변환
{
return SmartPtr<MusicProduct>(pointee);
}
...
private:
CD* pointee;
};
- 이 방법의 두 가지 단점
- SmartPtr 클래스를 타입 매개변수에 귀속되는 구현이므로 템플릿의 취지와는 충돌한다.
- 가리키는 객체가 가지고 있는 클래스 계통 구조가 깊으면 깊을수록 더 많은 변환 연산자를 추가해야 한다.
- 컴파일러가 알아서 암시적 타입 변환을 해주면 되는 거 아냐?
- 다행스럽게도 (비가상) 멤버 함수 템플릿(멤버 템플릿)을 선언해서 해결할 수 있다.
template<typename T>
class SmartPtr
{
public:
SmartPtr(T* realPtr = 0);
T* operator->() const;
T& operator*() const;
// 암시적 변환 연산자를 위한 템플릿 함수
template<typename newType>
operator SmartPtr<newType>()
{
return SmartPtr<newType>(pointee);
}
...
};
- 이제 아래의 코드는 정상적으로 컴파일된다.
void displayAndPlay(const SmartPtr<MusicProduct>& pmp, int howMany);
SmartPtr<Cassette> funMusic(new Cassette("Alapalooza"));
SmartPtr<CD> nightmareMusic(new CD("Disco Hits of the 70s"));
displayAndPlay(funMusic, 10); // 컴파일 성공
displayAndPlay(nightmareMusic, 0); // 컴파일 성공
- 컴파일 성공 과정
- displayAndPlay 함수는 SmartPtr<MusicProduct> 객체를 받도록 되어 있다.
- 타입 불일치를 감지하고 funMusic을 SmartPtr<MusicProduct> 객체로 바꿀 수단을 찾기 시작한다.
- SmartPtr<MusicProduct> 클래스에서 SmartPtr<Cassette>를 취하는 단일 인자 생성자를 찾지만, 실패한다.
- SmartPtr<Cassette> 클래스 안에서 암시적 타입 연산자(SmartPtr<MusicProduct>로 바꿔주는)를 찾지만, 실패한다.
- 암시적 타입 변환을 해주는 멤버 함수 템플릿을 찾고, SmartPtr<Cassette>안에서 찾아낸다.
- 다음과 같이 함수를 찍어낸다.
-
SmartPtr<Cassette>::operator SmartPtr<MusicProduct>() { return SmartPtr<MusicProduct>(pointee); }
- SmartPtr<MusicProduct>의 생성자가 pointee를 받아서 정상 처리됨을 확인한다.
- 스마트 포인터 타입의 암시적 변환 성공!
- 이는 업 캐스팅 뿐 아니라 다운 캐스팅도 해내는 방법이므로 강력하다.
- 하지만 아래의 경우는 컴파일이 실패하게 된다.
// 새로운 하위 클래스
class CasSingle : public Cassette { ... }
template<typename T>
class SmartPtr { ... };
// displayAndPlay의 두 가지 버전
void displayAndPlay(const SmartPtr<MusicProduct>& pmp, int howMany);
void displayAndPlay(const SmartPtr<Cassette>& pc, int howMany);
// 스마트 포인터 선언
SmartPtr<CasSingle> dumbMusic(new CasSingle("Achy Breaky Heart"));
displayAndPlay(dumbMusic, 1); // 어느 버전을 선택할지 모호하기에 컴파일 에러!!!
- 이것 외에 추가적인 스마트 포인터의 변환을 멤버 템플릿으로 구현하는 방법의 두 가지 단점
- 멤버 템플릿을 제대로 지원하는 환경이 많지 않기 때문에 이식성이 떨어진다.
- 하지만 이는 옛날 이야기로, 이제는 웬만하면 지원할 것이다.
- 이 방법의 동작 원리를 제대로 이해하기 힘들다.
- 함수 호출 시의 인자 일치 규칙을 알아야 한다.
- 암시적 타입 변환 함수 및 암시적 템플릿 함수 인스턴스화도 알아야 한다.
- 멤버 함수 템플릿도 알아야 한다.
- 멤버 템플릿을 제대로 지원하는 환경이 많지 않기 때문에 이식성이 떨어진다.
- 그래서 결론은?
- 스마트 포인터 클래스가 상속 기반의 타입변환에 대해서도 raw 포인터처럼 동작하도록 구현할 수 있을까?
- 절대로 불가능하지만, 위의 방법들을 사용해서 어색하게나마 보완할 수 있다.
스마트 포인터와 const
- raw 포인터에 대해서는 const를 두 가지 용법으로 사용할 수 있다.
- 포인터가 가리키는 것을 상수로 한다.
- 포인터 그 자체를 상수로 한다.
CD goodCD("Flood");
// 상수 CD 객체에 대한 비상수 포인터
const CD* p;
// 비상수 CD 객체에 대한 상수 포인터
// 상수 포인터이므로 초기화가 필수이다.
CD* const p = &goodCD;
// 상수 CD 객체에 대한 상수 포인터
const CD* const p = &goodCD;
- 이 개념을 스마트 포인터에도 어떻게 적용시킬 수 있지 않을까?
- 스마트 포인터의 경우에는 포인터에 대해서만 상수로 만들 수 있다.
// 비상수 CD 객체에 대한 상수 스마트 포인터
const SmartPtr<CD> p = &goodCD;
- 아니, 이렇게 하면 객체에 대해서도 상수화 할 수 있지 않을까?
// 상수 CD 객체에 대한 비상수 스마트 포인터?
SmartPtr<const CD> p = &goodCD;
- 하지만 대입에서 이 스마트 포인터가 가리키는 객체 상수화의 문제점이 드러난다.
CD* pCD = new CD("Famous Movie Themes");
const CD* pConstCD = pCD; // raw 포인터의 경우 컴파일 성공
SmartPtr<CD> pCD = new CD("Famous Movie Themes");
SmartPtr<const CD> pConstCD = pCD; // 컴파일 에러!!!
// SmartPtr<CD>와 SmartPtr<const CD>는 완전히 다른 타입이다.
- 이를 해결하기 위해서는 타입을 변환해주는 함수가 있어야 한다.
- 앞에서처럼 암시적 타입 변환 연산자를 사용할 수 있을 것이다.
- const가 붙은 포인터나 객체 사이의 타입 변환은 일방통행임을 명심하며 구현해야 한다.
- 비상수 -> 상수는 안전하지만, 상수 -> 비상수는 위험하다.
- 이는 public 상속 규칙과 흡사하다.
- 파생 클래스 -> 기본 클래스는 안전하지만, 기본 클래스 -> 파생 클래스는 위험하다.
- 그렇다면 이 특성을 활용해서 스마트 포인터 구현에 응용할 수 있지 않을까?
- T 객체 스마트 포인터 클래스와 const T 객체 스마트 포인터 클래스를 is-a 상속 관계로 만든다.
template<typename T>
class SmartPtrToConst
{
...
protected:
// T*용, const T*용 포인터 공간을 각각 마련하는 것은 아무래도 메모리 낭비이므로
// union을 사용해서 처리한다.
union
{
const T* constPointee; // SmartPtrToConst용
T* pointee; // SmartPtr용
};
};
template<typename T>
class SmartPtr : public SmartPtrToConst<T> { ... }
정리
- 스마트 포인터는 구현하기 까다롭고, 이해하기 쉽지 않으며, 유지보수도 어렵다.
- 더군다나 아무리 노력해도 raw 포인터를 완벽하게 대신할 수 있는 스마트 포인터는 절대 설계할 수 없다.
- 그렇지만, 그럼에도 불구하고 스마트 포인터는 너무나 매력적이므로 꼼꼼히 잘 따져서 적절히 사용할 것.
728x90
'개발 > More Effective C++' 카테고리의 다른 글
[More Effective C++] 30. 프록시 클래스 (0) | 2024.10.04 |
---|---|
[More Effective C++] 29. 참조 카운팅 (0) | 2024.09.27 |
[More Effective C++] 27. 힙 전용, 힙 불가 클래스 만들기 (1) | 2024.09.06 |
[More Effective C++] 26. 클래스 인스턴스 개수 제한 (0) | 2024.09.05 |
[More Effective C++] 25. 함수를 가상 함수처럼 만들기 (4) | 2024.09.04 |
Comments