산타는 없다

Techniques - 항목 28 : 스마트 포인터 (Smart Pointer) 본문

프로그래밍 서적/More Effective C++

Techniques - 항목 28 : 스마트 포인터 (Smart Pointer)

LEDPEAR 2021. 10. 6. 20:38
반응형

기본 포인터 대신 스마트 포인터를 사용 해야하는 이유

  • 생성(construction)과 소멸(destruction) 작업을 조절할 수 있다.
    - 생성되고 소멸되는 시기를 결정할 수 있으며, 기본값 0(null)을 가지기 때문에, 값을 주지 않으면 초기화되지 않는 기본 포인터가 일으키는 문제를 원천적으로 봉쇄한다.
  • 복사(copy)와 대입(assignment) 동작을 조절할 수 있다.
    - 스마트 포인터가 복사되거나 대입될 때 일어나는 일을 결정할 수 있다.
  • 역참조(dereferencing) 동작을 조절할 수 있다.
    - 사용자가 스마트 포인터가 가리키는 객체를 가져오려고 할 때 어떤 일이 일어날지 결정할 수 있다.

스마트 포인터는 템플릿 기반으로 만들어진다. 그 이유는 가리킬 타입이 정확하게 지정되어야 하기 때문이다.

이 뿐만 아니라 객체들이 여러 컴퓨터에 흩어져 있는 분산 시스템에서 로컬 객체와 원격객체의 처리방법을 동일하게 사용하고 싶을 때도 스마트 포인터를 사용할 수 있습니다.


스마트 포인터의 생성, 대입, 소멸

스마트 포인터를 생성하는 방법은 가리킬 객체를 하나 준비하고, 스마트 포인터의 내부에 있는 기본 포인터를 그 객체의 주소로 세팅하면 되는데 복사 생성자와 대입 연산자, 그리고 소멸자를 구현하는 방법은 소유권(ownership) 때문에 복잡하다.

auto_ptr같은 스마트 포인터는 복사가 일어날 때, 복사되는 객체로 소유권을 넘기고 기존의 객체는 널값을 가지게 됩니다.

때문에 auto_ptr을 매개변수를 가지는 함수를 쓰게되면 매개변수에 값이 복사(call by value)가 되면서 원본이 가리키는 값은 널이 됩니다. 때문에 auto_ptr를 매개변수로 사용해야 될 경우 값을 복사하는 것이 아닌 참조자를 전달하는 방식으로 수행해야 합니다.

그렇지 않았을 경우 해당 함수 이후에 원본 auto_ptr을 사용하는 곳에서 객체를 찾을 수 없어 에러가 발생하게 됩니다.


역참조(Dereferencing) 연산자 구현하기

이 부분은 스마트 포인터의 심장부라고 말할 수 있는 operator * 와 operator-> 함수 입니다.

operator *

  • 가지고 있는 포인터가 가리키고 있는 객체가 유효한지 확인하고 만약 유효하지 않다면 초기화와 같은 과정을 통해 유효하게 만들거나 예외를 반환한다.
  • 가지고 있는 포인터의 참조자를 반환한다.
  • 만약 참조자를 반환하지 않는다면 가리키는 객체와 반환하는 객체의 타입이 다를 수 있다
    (T에서 파생된 클래스의 객체를 가리키는 경우)

operator ->

  • operator * 크게 다르지 않다
  • 반환할 수 있는 것은 기본 포인터 또는 스마트 포인터 객체 중 하나이다.
  • 기본 포인터를 반환하는 경우가 대부분이다.

스마트 포인터가 널(null)인지 점검하기

암시적 타입변환 연산자를 통해 기본 포인터 처럼 사용할 수 있도록한다. 이런 목적으로 타입변환을 할 때에는 대개 void * 로 바꾸며, operator void * 를 오버로딩하여 구현한다.

하지만 서로 다른 타입의 스마트 포인터의 비교까지 가능하게 만들어버리는 단점이 있다.

때문에 operator! 연산자를 오버로딩하여 연산자가 호출된 스마트 포인터가 널일 때에만 true를 반환하도록 한다.

이랬을 땐 if(ptn == 0) 이나 if(ptn) 와 같은 코드는 컴파일되지 않는다.

단, 위와 같은 방법을 통해 스마트 포인터가 널인지 점검을 해도 if(!pa == !po)와 같이 비교를 하면 컴파일이 되므로 조심해야 한다.

C++ 라이브러리 표준에서는, void * 타입로의 암시적 변환이 bool 타입으로의 암시적 변환으로 대체되었고, operator bool이 항상 operator!의 부정 결과를 반환하도록 되어 있다.


스마트 포인터를 기본 포인터로 변환하기

기존에 기본 포인터를 사용하던 함수에 스마트 포인터를 넘기면 컴파일되지 않는다.

이 때, T에 대한 스마트 포인터 템플릿에다 T에 대한 기본 포인터로의 암시적 변환 연산자를 추가해 주면 원하는 호출 코드가 매끄럽게 컴파일 된다.

operator T* () { return pointee;}

이 변환 연산자 함수를 추가하면, 스마트 포인터의 널 점검에 대한 문제도 해결됩니다.

하지만 이럴 경우 사용자들은 기본 포인터와 스마트 포인터의 차이를 잊고 기본 포인터를 직접 사용하기쉽기 때무에, 스마트 포인터가 주려고 했던 혜택을 받지 못할 수 있다.

Tuple *rawTruplePtr = pt /* pt는 Tuple 타입의 스마트 포인터 */ /*이 처럼 사용한다면 기본 포인터를 사용하는 것과 차이가 없다 */

또한 사용자 정의 타입변환을 한 번에 두 번 이상 할 수 없기 때문에 기본 포인터가 있던 자리를 완벽하게 대체할 수 없으며 delete pt 와 같은 동작하면 안되는 코드도 문제없이 컴파일이 된다. (이러면 pt가 가리키는 객체가 두번 delete 된다.)

꼭 그렇게 해야하는 이유가 없다면 스마트 포인터를 기본 포인터로 바꾸는 암시적 타입변환 연산자는 제공하지 말아야한다.


스마트 포인터와 상속 기반의 타입변환

아무런 조치 없이 스마트 포인터로 생성된 파생 클래스 객체를 기반 클래스를 매개변수로 하는 함수에 넣으면 컴파일 에러가 발생합니다.

컴파일러 시점으로 보면 서로 아무 관계도 없다고 인식하기 때문입니다.

이러한 제한을 벗어나는 방법으로는 스마트 포인터 클래스 각각에다가 암시적 타입 연산자를 만들어서, 다른 스마트 포인터 클래스로 암시적 변환이 가능하게 하는 것이다.

그러나 이 방법은 두 가지 단점을 가집니다.
첫째, 각각 타입에 맞는 암시적 타입변환 연산자를 추가해야 하기 때문에 템플릿의 취지를 흔드는 결과를 낳는다.
둘째, 가리키는 객체가 가지고 잇는 클래스 계통 구조가 깊으면 그만큼 더 많은 변환 연산자를 추가해야 한다.

따라서 위의 방법을 사용하지 않고 최근에 확장된 C++ 언어에서 지원하는 기능을 사용한다. 이 기능은 (비가상) 멤버 함수 템플릿(보통, 멤버 템플릿이라고 부른다)이라고 한다.

template <class T>
class SmartPtr{
public:
	
    SmartPtr(T* realPtr = 0);
    
    T* operator->() const;
    T& operator* () const;
    
    template <class newType>
    operator SmartPtr<newType>()
    {
    	return SmartPtr<newType> (pointee);
    }
    ....
};

컴파일러는 변환하고자 하는 타입 T의 기본 클래스에 바인딩된 newType 매개변수를 써서 함수 템플릿을 함수로 인스턴스화합니다.

위 코드는 기본 클래스의 스마트 포인터를 반환하기 위해 다음과 같이 만들어집니다.

SmartPtr<Cassette>::operator SmartPtr<MusicProduct>()
{
	return SmartPtr<MusicProduct>(pointee);
}

단 실행되는 함수가 오버로딩되었고 그 함수가 둘 중 하나라도 바로 위의 기반 클래스가 아니라면 컴파일러는 어떤 것이 기반 클래스를 매개변수로 하는 함수인지 판단하지 못하고 모호하다는 에러를 낸다. 단, 기존 포인터라면 가능하다.

이것 외에 스마트 포인터의 변환을 멤버 템플릿으로 구현하는 방법에는 단점이 두 가지 더 있다.
1. 멤버 템플릿을 제대로 지원하는 환경이 많지 않기 때문에 이식성이 떨어진다. (최근 컴파일러는 모두 이 기능을 지원)
2. 이 방법의 동작원리를 제대로 이해하기 힘들다.

결론은 스마트 포인터 클래스가 상속 기반의 타입변환에 대해서도 벙어리 포인터 처럼 동작하도록 구현할 수 있는냐에 대한 답은 절대로 하지 않아야한다는 것이다.

우리가 할 수 있는 일은 템버 함수 템플릿을 써서 변환 함수를 만들어 모호성 문제가 발생하는 경우에 적절한 캐스팅을 적용하는 것뿐이다.


스마트 포인터와 const

스마트 포인터에 const를 적용할 때 기본 포인터와 다르게 키워드를 놓을 수 있는 위치는 한 곳 뿐이다. 스마트 포인터의 경우에는 가리키는 객체가 아니라 포인터만 상수로 만들 수 있다.

const SmartPtr<CD> p = &goodCD;    // 비상수 CD 객체에 대한 상수 스마트 포인터

상수 객체에 대한 스마트 포인터는 다음과 같이 선언할 수 있다.

SmartPtr<const CD> p = &goodCD;

하지만 이와 같은 경우에 실제로 데이터를 넣는다 생각하면

SmartPtr<CD> pCD = new CD("Famous Movie Themes");
SmartPtr<const CD> pConstCD = pCD;

처럼 데이터를 넣게 되는데 SmartPtr<CD>와 SmartPtr<const DC>는 완전히 다른 타입이기 때문에 컴파일 에러가 발생합니다.

비상수와 상수 객체의 관계는 public 상속에 관한 규칙과 흡사하다는 것에 착안해

tmeplate <class T>
class SmartPtrToConst{
	...
    
protected:
	union{
    	const T* constPointee;
        T* pointee;
    };
};

template <class T>
class SmartPtr : public SmartPtrToConst<T>{
	...	//데이터 멤버는 없다
};

위와 같이 공용체(union)을 활용하면 SmartPtr 객체가 필요없는 SmartPtrToConst를 하나 더 가지지 않고도 원하는 기능을 구현할 수 있게 되고 메모리 공간을 절약할 수 있다.

SmartPtr<CD> pCD = new CD("Famous Movie Themes");
SmartPtrToConst<CD> pConstCD = pCD;

위 처럼 정상적으로 기능 구현이 가능합니다.


총정리

  • 널 점검인 기본 포인터로의 변환, 상속 기반의 변환, 상수객체에 대한 포인터 지원 등의 상황에서는 스마트 포인터의 사용을 제한해야 한다.
  • 스마트 포인터는 구현하기 까다롭고, 이해하기도 쉽지 않으며 이우의 유지보수도 어렵다
  • 스마트 포인터를 사용한 코드는 보통의 기본 포인터를 사용한 코드보다 디버깅이 훨씬 어렵다.
  • 아무리 노력해도 기본 포인터를 완벽하게 대신할 수 있는 일반적인 용도의 스마트 퐁니터는 절대로 설계할 수 없다는 점을 잊어선 한된다.
  • 그럼에도 불구하고, 스마트 포인터는 다른 방법으로 구현하기 힘든 기능을 구사하는데 표과적이다.
반응형
Comments