예외를 던지는 것은 매우 쉽습니다. 그에 반해서 예외를 받아서 처리하기 위해서는 많은 고민을 해야 합니다. 그래서 예외를 던지고 받는 구조가 명확하지 않다면 함부로 예외를 던져서는 안됩니다. 이 글에서는 클래스 생성자에서 예외가 발생했을 때의 동작 흐름을 명확하게 이해함으로써 던져진 예외를 잘 처리하기 위한 방법
new 연산자는 예외를 던집니다
C 언어를 배우고 C++ 프로그래밍 하는 개발자가 흔히 저지르는 실수가 new 연산자가 메모리 할당을 실패했을 때 malloc과 동일하게 NULL 포인터가 반환된다고 생각하는 것입니다. 그러나, C++에서 new 연산자는 메모리를 할당할 수 없는 경우 예외를 던집니다 – new 연산자가 예외를 던지기까지 몇 가지 단계를 거치며, 심지어 예외대신 NULL 포인터를 반환하는 new 연산자가 존재하지만 여기서는 new 연산자에 의해 발생하는 최종 결과인 예외가 던져진 상황만 언급했습니다.
이는 객체의 생성자에서 new 연산자로 객체를 생성하려는 시도가 실패한다면 객체를 생성하는 과정에서 예외가 발생함을 의미합니다. 따라서 객체를 생성할 때는 항상 예외가 발생할 수 있다고 생각해야 합니다.
객체의 생성자에서 예외가 발생했을 때의 동작을 알아보기 위해 두 개의 클래스를 작성했습니다. 두 클래스는 모두 문자열을 저장하는 것 이외에는 아무 역할도 하지 않지만 객체가 생성되고 소멸되는 것을 관찰할 수 있도록 생성자와 소멸자에 로그 문자열을 출력합니다. Memeber 클래스와 달리 InvalidMember 클래스는 생성자에서 예외를 던지도록 했고 InvalidMember 클래스의 생성은 항상 실패하게 됩니다.
[ 리스트 1 ]
class Member
{
public:
Member(const std::string &msg)
: _myMsg(msg) {
std::cout << "Member : " << msg << std::endl ;
}
virtual ~Member() {
std::cout << "~Member : " << _myMsg << std::endl ;
}
private:
std::string _myMsg ;
} ;
class InvalidMember
{
public:
InvalidMember(const std::string &msg)
: _myMsg(msg) {
std::cout << "InvalidMember : "
<< msg << std::endl ;
throw std::exception("force throw") ;
}
virtual ~InvalidMember() {
std::cout << "~InvalidMember : "
<< _myMsg << std::endl ;
}
private:
std::string _myMsg ;
} ;
다음과 같이 Member 클래스와 InvalidMember 클래스를 생성합니다.
[ 리스트 2 ]
class ExceptionDuringCtor
{
public:
ExceptionDuringCtor()
: _member1(new Member())
, _invalidMember(new InvalidMember())
, _member2(new Member()) {
}
private:
Member* _member1 ;
InvalidMember* _invalidMember ;
Member* _member2 ;
};
ExceptionDuringCtor exceptionDuringCtor ;
ExceptionDuringCtor은 _member1, _invalid, _member2 순서로 멤버를 초기화합니다. InvalidMember 클래스의 생성은 항상 실패할 것이기 때문에 _member1은 Member 객체를 참조하지만 _invalid와 _member2는 초기화되지 않습니다. 예외가 발생했을 때 C++의 예외에 대한 규칙을 짚어보면 다음과 같습니다.
- 첫째, 예외가 발생하면 모든 지역 변수에 대해서 unwinding이 이루어진다.
- 둘째, 객체의 생성자에서 예외가 발생하면 객체가 생성되는 동안 초기화되었던 모든 멤버는 해제된다.
이 규칙을 해석하는 데 주의해야 할 점은 메모리와 같은 시스템 리소스에 대한 반환 요구가 없다는 점입니다. 그래서 ExceptionDuringCtor 같이 클래스의 생성자에서 예외가 발생했을 경우 _member1, _invalidMember, _member2에 할당된 객체는 해제되지 않습니다.
C++의 예외 규칙에 의하면 객체의 생성자에서 예외가 발생했을 경우 생성자에서 초기화된 객체를 다시 해제하기 때문에 ExceptionDuringCtor 클래스와 같이 객체 생성이 실패한 경우 소멸자를 호출하지 않습니다. 설령 객체의 소멸자가 호출되더라도 _invalidMember와 +members는 초기화가 되지 않았기 때문에 새로운 객체를 가리키지도 NULL로 초기화되지도 않습니다. 따라서, 이들 멤버에 대해서 대해서 안전하게 delete를 호출할 방법은 없습니다.
function-try-contructor
생성자가 실패한 경우 멤버에 할당된 시스템 리소스를 안전하게 반환하기 위해서는 function-try-contructor를 사용해야 합니다. 리스트 3은 function-try-constructor를 사용하여 멤버를 NULL로 초기화하고 try 블록에서 객체를 생성하며, 예외가 발생했을 경우 각 멤버가 가리키는 객체를 해제하는 것을 보여줍니다.
[ 리스트 3 ]
class SafeExceptionDuringCtor
{
public:
SafeExceptionDuringCtor() try
: _member1(NULL)
, invalidMember(NULL)
, _member2(NULL) {
_member1 = new Member1("1st") ;
_invalidMember = new InvalidMember1("out") ;
_member2 = new Member1("last") ;
}
catch ( std::exception& ) {
if ( _member1 ) delete _member1 ;
if ( _invalidMember ) delete invalidMember ;
if ( _member2 ) delete _member2 ;
}
private:
Member1* _member1 ;
InvalidMember1* _invalidMember ;
Member2* _member2 ;
};
이 코드는 일단 포인터를 모두 NULL로 초기화한 뒤에 객체를 생성하여 할당하므로 객체가 할당되지 않은 객체는 NULL로 초기화된 채로 남게 됩니다. 이를 이용해서 catch 구문에서 NULL이 아닌 객체를 해제하고 있는데 코드가 다소 복잡해 보입니다.
위의 코드는 스마트 포인터를 사용해서 다음과 같이 훨씬 간결하게 수정할 수 있습니다.
[ 리스트 4 ]
class SafeExceptionDuringCtor
{
public:
SafeExceptionDuringCtor()
: _member1(new Member("1st"))
, _invalidMember(new InvalidMember("out"))
, _member2(new Member("last"))
{
}
private:
std::auto_ptr _member1 ;
std::auto_ptr _invalidMember ;
std::auto_ptr _member2 ;
};