프로그래밍 언어/C++

C++ 예외 처리용 throw(), noexcept()

ShovelingLife 2022. 6. 16. 15:59

throw(typeidtypeid, ...)

throw 한정자는 함수가 예외로 던질 수 있는 typeid의 목록을 인자로 받는다. typeid에 해당하는 타입이 클래스나 구조체라면, 상속받는 자식도 역시 예외를 던질 수 있는 타입으로 간주한다.

// 아무 타입도 지정하지 않았으므로, 예외를 던지지 않는다.
void no_except() throw();
 
// 모든 타입에 대해 예외를 던진다
void bar() throw(...) {};    // C++11부터 추가된 parameter pack, 즉 C++11 이전엔 이 형태가 불가능
void baz() {};
 
class X {};
class Y {};
class Z : public X {};
class W {};
 
// 함수 f는 X와 Y, 그리고 그들의 자식을 예외로 던질 수 있다.
void foo() throw(X, Y)
{
    int n = 0;
 
    if (n)
        throw Y();    // OK
 
    if (n)
        throw Z();    // Z는 X의 자식이므로, OK
 
    // 예외로 던질 수 없는 걸 던졌으므로, std::unexpected 호출
    throw W();
}

 

std::unexpected가 호출되면 std::unexpected_handler를 호출하는데, 기본 handler 함수는 std::terminated이다. std::terminated가 호출되면 std::terminated_handler를 호출하는데, 기본 handler 함수는 std::abort이다. std::abort가 호출되면, 프로그램이 조용히 종료되어 버린다. unhandled exception이 발생하며 크래쉬가 발생하면, 덤프를 통해서 문제를 해결할 수 있는데 말이다. (물론, 이 문제는 noexcept도 마찬가지이다) 게다가, throw() 한정자는 다음의 문제들을 가지고 있다.
// 1. 예외 한정 확장의 경우
// 처음엔 하나의 예외만 던질 수 있었다가...
virtual void open() throw(FileNotFound);
 
// 다음과 같이 여러 개의 예외를 던질 수 있는 식으로 점점 확장될 수 있다.
// 만약 throw()에 포함되지 않는 타입이 예외를 던지게 되면, std::unexpected가 호출된다.
// 이는 원하지 않는 결과인 것이다.
virtual void open() throw(FileNotFound, SocketNotReady, InterprocessObjectNotImplemented, HardwareUnresponsive);
 
 
// 2. 예외 한정 불가의 경우
struct<typename T>
{
    // T 타입을 생성시키는 함수
    // T 타입에 따라 생성자가 예외를 던질수도 아닐수도 있다.
    // 이 경우엔 throw()를 통해 문제를 해결할 수 없다.
    void CreateOtherClass() { T t{}; }
};
이러한 throw의 문제점들이 인지되어, C++11부터 throw() 한정자는 deprecated 되었다. VS2013까지는 _NOEXCEPT가 throw()로 define되어 있으며, throw() 한정자가 deprecated되고, noexcept키워드가 반영되어 있지 않다.

2. noexcept

C++11(VS2015)부터 throw()가 deprecated 되고, noexcept 키워드가 추가되었다. noexcept 키워드는 연산자(operator)의 형태로, 그리고 한정자(specifier)의 형태로 제공된다. noexcept() 한정자는 모든 면에서 throw()보다 강력해졌고, 특히 C++11들의 Standard library들을 사용함에 있어, noexcept 한정자는 성능상의 추가 이득을 제공하기도 한다.

noexcept(expression)

noexcept 연산자는 컴파일 타임에 해당 표현식이 예외를 던지지 않는 표현식인지 체크하여, true/false를 반환한다.
표현식이 다음의 경우 중 하나라도 포함하고 있으면, noexcept는 false를 반환하며, 그렇지 않은 경우엔 true를 반환한다.
  • 상수 표현식이 아닌 함수가 noexcept 키워드를 가지지 않을 경우
  • 런타임 체크가 필요한 dynamic_cast 등의 RTTI가 포함된 경우
  • typeid 표현식에 포함된 타입이 상속 관계에 있는 클래스나 구조체일 경우
#include <iostream>
#include <utility>
#include <vector>
 
// 예외를 던질 수 있음
void may_throw();
// 예외를 던지지 못함
void no_throw() noexcept;
 
// 예외를 던질 수 있는 람다
auto lmay_throw = [] {};
// 예외를 던지지 않는 람다
auto lno_throw = [] () noexcept {};
 
class T
{
public:
    // 명시적인 소멸자 선언으로 인해 이동생성자/이동연산자 암시적 생성 금지
    // 하지만 복사생성자/대인연산자는 noexcept로 암시적 생성
    ~T() {}
};
 
class U
{
    std::vector<int> v;
 
public:
    // 명시적인 소멸자 선언으로 인해 이동생성자/이동연산자 암시적 생성 금지
    // 하지만, 복사생성자/대입연산자는 noexcept(false)로 암시적 생성
    // vector<int> v의 복사생성자/대입연산자가 noexcept가 아니기 때문...
    ~U() {}
};
 
class V
{
public:
    // 복사생성자/대입연산자는 noexcept(false)로 암시적 생성
    // 이동생성자/이동연산자는 noexcept로 암시적 생성
    // vector<int> v의 복사생성자/대입연산자가 noexcept가 아니기 때문...
    std::vector<int> v;
};
 
int main()
{
    T t;
    U u;
    V v;
 
    noexcept(may_throw());      // false
    noexcept(no_throw());       // true
    noexcept(lmay_throw());     // false       
    noexcept(lno_throw());      // true
 
    noexcept(std::declval<T>().~T());   // true  (기본적으로 유저 정의 소멸자는 noexcept)
    noexcept(T(std::declval<T>()));     // true  (T(rvalue t)는 이동생성자가 없으므로 복사생성자가 noexcept)
    noexcept(T(t));                     // true  (T(lvalue t) 복사생성자가 noexcept)
    noexcept(U(std::declval<U>()));     // false (U(rvalue u)는 이동생성자가 없으므로 복사생성자가 noexcept(false))
    noexcept(U(u));                     // false (U(lvalue u)는 복사생성자가 noexcept(false))
    noexcept(V(std::declval<V>()));     // true  (V(rvalue v)는 이동생성자가 noexcept)
    noexcept(V(v));                     // false (V(lvalue v)는 복사생성자가 noexcept(false))
}
// T() 생성자가 예외를 던지느냐에 따라, foo 함수의 예외 던질지 여부가 결정된다
// 기존 throw() 로는 이러한 처리가 불가능하다
// noexcept(T())는 operator로써, 이를 감싸는 외부 noexcept는 specifier로 사용되었다.
template <typename T>
void foo() noexcept(noexcept(T())) {}
 
// 예외를 던지지 않는다
void bar() noexcept(true) {}
// 예외를 던지지 않는다고 명시해놓고 예외를 던졌다.
void baz() noexcept { throw 42; }  
 
int main()
{
    // noexcept(noexcept(int())) => noexcept(true) OK
    foo<int>();
 
    bar();  // OK
    baz();  // 컴파일은 문제 없으나, 런타임에 std::terminate가 호출된다.
}
함수에서 예외를 던지지 않겠다고 명시한 다음 예외를 던지면 std::terminated가 호출된다. std::terminated가 호출되면 std::terminated_handler를 호출하는데, 기본 handler 함수는 std::abort이다. std::abort가 호출되면, 프로그램이 조용히 종료되어 버린다.
이는 throw에서도 문제로 지적되었던 것인데 throw와의 차이점은 다음과 같다.
  • throwstd::unexpected를 호출하였고, noexceptstd::terminated를 호출한다.
  • throw와는 달리 noexceptstack unwinding이 발생할수도 아닐 수도 있어 컴파일러가 조금 더 최적화를 할 수 있게 한다.
그리고 다음의 함수들은 기본적으로 noexcept 를 가진다.
  • 암시적으로 생성되는 기본 생성자, 복사 생성자, 대입 연산자, 이동 생성자, 이동 연산자, 소멸자
  • 유저가 명시적으로 noexcept(false)로 선언하거나, 부모의 소멸자가 그러하지 않은 경우를 제외한 모든 유저 정의 소멸자
  • operator delete 함수 씨리즈 (할당 해제 함수들)
이 중 암시적으로 생성되는 복사/이동 생성자나 대입/이동 연산자는 자신의 멤버들이 복사나 이동시 noexcept를 보장해야 이들의 생성자나 연산자가 기본적으로 noexcept를 가질 수 있다.
class T
{
public:
    // 명시적인 소멸자 선언으로 인해 이동생성자/이동연산자 암시적 생성 금지
    // 하지만 복사생성자/대인연산자는 noexcept로 암시적 생성
    ~T() {}
};
 
class U
{
    std::vector<int> v;
 
public:
    // 명시적인 소멸자 선언으로 인해 이동생성자/이동연산자 암시적 생성 금지
    // 하지만, 복사생성자/대입연산자는 noexcept(false)로 암시적 생성
    // vector<int> v의 복사생성자/대입연산자가 noexcept가 아니기 때문...
    ~U() {}
};
 
class V
{
public:
    // 복사생성자/대입연산자는 noexcept(false)로 암시적 생성
    // 이동생성자/이동연산자는 noexcept로 암시적 생성
    // vector<int> v의 복사생성자/대입연산자가 noexcept가 아니기 때문...
    std::vector<int> v;
};
noexcept 연산자는 컴파일 타임에 평가되지만, noexcept 한정자는 컴파일 타임에 평가되지 않음을 주의해야 한다. noexcept 한정자가 붙은 함수가 실제 예외를 던지게 되면, 런타임에 std::terminated가 호출된다는 것을 다시 한번 기억하기 바란다.

3. 언제 써야할까?

noexcept 한정자가 붙은 함수에 대해서 컴파일러는 특정한 최적화를 진행할 수 있다. 하지만 최적화 목적으로 사용은 비추한다. noexcept를 쓰는 가장 큰 이유는 strong exception guarantee여야 한다. 이것이 왜 필요하냐? C++11 이후 std::vector뿐 아니라 STL 컨테이너들은 move semantics가 모두 적용되어 있다. 원소에 대한 이동 처리를 할 때 해당 원소가 move시 noexcept를 지원하지 않으면, move semantics가 아닌 copy semantics로 element를 처리한다.즉, 이동 처리에 대한 strong exception guarantee가 되어 있지 않으면 move semantics의 장점을 포기하는 것이다.
#include <iostream>
#include <vector>
 
struct foo
{
    int value;
 
    explicit foo(int value) : value(value)
    {
        std::cout << "foo(" << value << ")\n";
    }
 
    foo(foo const& other) : value(other.value)
    {
        std::cout << "foo(foo(" << value << "))\n";
    }
 
    foo(foo&& other) noexcept : value(std::move(other.value))
    {
        other.value = -1;
        std::cout << "foo(move(foo(" << value << "))\n";
    }
 
    ~foo()
    {
        if (value != -1)
            std::cout << "~foo(" << value << ")\n";
    }
};
 
int main()
{
    std::vector<foo> foos;
    foos.emplace_back(1);
 
    // 이때, 벡터의 확장을 위해 reserve가 발생.
    // 만약 foo의 이동 생성자가 noexcept를 보장하지 않으면, copy construction이 발생한다
    // 즉, reallocating 과정에서 이동이 아닌 복사가 발생하여 성능 이득을 얻지 못하게 되는 것이다
    foos.emplace_back(2);
}
이는 vector의 reallocating이 발생하는 resize의 경우도 마찬가지이다. 이러한 처리는 noexcept가 도입된 C++11 컴파일러(VS2015)의 STL 컨테이너에 전반적으로 동일하게 적용된다.

4. move_if_noexept

STL 컨테이너들의 move semantics가 strong exception guarantee를 요구하는 것과 관련하여,  C++11(VS2015) 라이브러리(utility.h)에 추가된 하나의 새로운 trait 템플릿을 소개한다. 
template <typename T>
typename std::conditional<
    !std::is_nothrow_move_constructible<T>::value && std::is_copy_constructible<T>::value,
    const T&,
    T&&
>::type move_if_noexcept(T& x);
 
이동(move) 또는 복사(copy)될 수 있는 "X" 를 인자로 받아,  이동 생성자가 noexcept이면 std::move(X)를 반환, 그렇지 않으면 X를 반환한다.
#include <iostream>
#include <utility>
 
struct Good
{
    Good() {}
    Good(Good&&) noexcept       // 예외를 던지지 않는다
    {
        std::cout << "Non-throwing move constructor called\n";
    }
    Good(const Good&) noexcept  // 예외를 던지지 않는다
    {
        std::cout << "Non-throwing copy constructor called\n";
    }
};
 
struct Bad
{
    Bad() {}
    Bad(Bad&&)          // 예외를 던질 수 있다
    {
        std::cout << "Throwing move constructor called\n";
    }
    Bad(const Bad&)     // 예외를 던질 수 있다
    {
        std::cout << "Throwing copy constructor called\n";
    }
};
 
int main()
{
    Good g;
    Bad b;
 
    // Good 이동 생성자는 예외를 던지지 않으므로, std::move(g)를 반환
    // 이동 생성자가 실행된다
    Good g2 = std::move_if_noexcept(g);
 
    // Bad 이동 생성자는 예외를 던질 수 있으므로, b를 반환
    // 복사 생성자가 실행된다
    Bad b2 = std::move_if_noexcept(b);
}

출처 : http://sweeper.egloos.com/3148916#comment_3148916