throw 표현식

throw 표현식 (예외 던지기)

C++에서 오류 상황을 알리는 대표적인 방법이 예외를 던지는 것(throw)이에요. 예외를 던지면 제어가 핸들러(handler)로 넘어가요. throw 표현식에서 예외를 던질 수 있고, 그 밖에도 다음과 같은 문맥에서 예외가 발생할 수 있어요.

  • 할당 함수(allocation functions)
  • dynamic_cast
  • typeid
  • new 표현식
  • 표준 라이브러리 함수들

출처: cppreference

본문

예외 객체 (Exception object)

예외를 던지면 **동적 저장 기간(dynamic storage duration)**을 가진 객체가 초기화되는데, 이 객체를 **예외 객체(exception object)**라고 불러요.

예외 객체의 타입이 다음 중 하나라면 프로그램은 ill-formed(형식에 어긋남)예요.

  • 불완전 타입(incomplete type)
  • 추상 클래스 타입
  • (cv 한정될 수 있는) void가 아닌 불완전 타입을 가리키는 포인터

예외 객체의 생성과 파괴 (Constructing and destructing exception objects)

예외 객체의 타입을 T라고 할 때:

  • obj를 타입 const T의 lvalue라고 하자. obj로부터 타입 T의 객체를 복사 초기화(copy-initialization)하는 것은 well-formed여야 해요.
  • T가 클래스 타입이라면:
    • 선택된 생성자(selected constructor)는 odr-used예요.
    • T의 소멸자는 잠재적으로 호출됩니다(potentially invoked).

예외 객체를 위한 메모리는 불특정한 방식으로 할당돼요. 유일한 보장은 그 저장 공간이 전역 할당 함수로는 절대 할당되지 않는다는 것이에요.

핸들러가 재던지기(rethrow)로 끝나면, 제어는 같은 예외 객체에 대한 다른 핸들러로 넘어가요. 이 경우 예외 객체는 파괴되지 않아요.

핸들러가 예외를 재던지지 않는 다른 방식으로 끝나면, 예외 객체가 파괴되고 구현은 임시 객체의 메모리를 불특정한 방식으로 해제할 수 있어요. (C++11까지는) 그 파괴는 핸들러의 "매개변수 목록"에 선언된 객체의 파괴 직후에 일어나요.

(C++11부터) 예외 객체의 잠재적 파괴 지점(points of potential destruction)은 다음과 같아요.

  • 예외에 대한 활성 핸들러가 재던지기가 아닌 어떤 방식으로 끝날 때, 핸들러의 "매개변수 목록"에 (있다면) 선언된 객체의 파괴 직후.
  • 예외 객체를 참조하는 std::exception_ptr 타입 객체가 파괴될 때, std::exception_ptr 소멸자가 반환되기 전.
  • 예외 객체의 모든 잠재적 파괴 지점 가운데 불특정한 마지막 지점이 하나 있는데, 거기서 예외 객체가 실제로 파괴돼요. 다른 모든 지점은 그 마지막 지점보다 앞서 일어나요. 구현은 그 후 예외 객체의 메모리를 불특정한 방식으로 해제할 수 있어요.

throw 표현식 (throw expressions)

표기 의미
throw expression (1) 새 예외를 던짐
throw (2) 현재 처리 중인 예외를 재던짐
  1. 새 예외를 던져요.
  2. 현재 처리 중인 예외를 재던져요.

새 예외를 던질 때, 그 예외 객체는 다음과 같이 결정돼요.

  • expression에 배열→포인터, 함수→포인터 표준 변환이 적용돼요.
  • 변환 결과를 ex라고 하자.
    • 예외 객체의 타입은 ex의 타입에서 최상위 cv 한정자를 제거해 결정돼요.
    • 예외 객체는 ex로부터 복사 초기화돼요.

현재 처리 중인 예외가 없는데 재던지기를 시도하면 std::terminate가 호출돼요. 그렇지 않으면 예외는 기존 예외 객체로 재활성화되고(새 예외 객체가 생성되지 않음), 그 예외는 더 이상 잡힌 것으로 간주되지 않아요.

try
{
    // 새 예외 123을 던짐
    throw 123;
}
catch (...) // 모든 예외를 잡음
{
    // 예외 123에 (부분적으로) 응답
    throw; // 예외를 다른 핸들러로 넘김
}

스택 되감기 (Stack unwinding)

예외 객체가 만들어진 뒤에는 제어 흐름이 호출 스택을 거꾸로 올라가며 try 블록의 시작점에 도달할 때까지 진행돼요. 거기서 모든 연관 핸들러의 매개변수가 나타난 순서대로 예외 객체의 타입과 비교되어 일치하는 것을 찾아요. 일치하는 것이 없으면 제어 흐름은 계속 스택을 되감아 다음 try 블록으로 가고, 이런 식으로 이어져요. 일치하는 핸들러를 찾으면 제어가 그 핸들러로 점프해요.

제어 흐름이 호출 스택을 올라가면서, 해당 try 블록에 들어간 이후 생성됐지만 아직 파괴되지 않은 자동 저장 기간을 가진 모든 객체에 대해, 생성자 완료 순서의 역순으로 소멸자가 호출돼요. 지역 변수의 소멸자나 return 문에 쓰인 임시 객체의 소멸자에서 예외가 던져지면, 함수에서 반환되는 객체의 소멸자도 함께 호출돼요.

객체의 생성자나 (드물게) 소멸자에서 예외가 던져지면(그 객체의 저장 기간과 무관하게), 완전히 생성된 모든 비정적·비변형(non-variant) 멤버와 기저 클래스에 대해 생성자 완료 순서의 역순으로 소멸자가 호출돼요. 유니온류 클래스의 변형 멤버(variant member)는 생성자로부터의 되감기 경우에만 파괴되는데, 초기화와 파괴 사이에 활성 멤버가 바뀌었다면 동작은 정의되지 않아요. (C++11부터) 위임 생성자(delegating constructor)가 비위임 생성자(non-delegating constructor)가 성공적으로 완료된 뒤에 예외로 끝나면, 이 객체에 대한 소멸자가 호출돼요.

new 표현식이 호출한 생성자에서 예외가 던져지면, 사용 가능하다면 짝이 되는 해제 함수(deallocation function)가 호출돼요.

이 전체 과정을 **스택 되감기(stack unwinding)**라고 불러요.

예외 객체의 초기화 이후, 예외 핸들러가 시작되기 전에, 스택 되감기 메커니즘이 직접 호출하는 어떤 함수가 예외로 끝난다면 std::terminate가 호출돼요. 이런 함수에는 스코프가 종료되는 자동 저장 기간 객체의 소멸자나, catch-by-value 인자를 초기화하기 위해 (생략되지 않았다면) 호출되는 예외 객체의 복사 생성자가 있어요.

예외가 던져졌지만 잡히지 않는 경우 — std::thread의 초기 함수, main 함수, 정적·스레드 지역 객체의 생성자나 소멸자를 빠져나가는 예외를 포함해서 — std::terminate가 호출돼요. 잡히지 않은 예외에 대해 스택 되감기가 일어날지 여부는 구현 정의(implementation-defined)예요.

참고 (Notes)

예외를 재던질 때는 예외 객체가 상속을 쓸 때 (전형적으로 발생하는) **객체 슬라이싱(object slicing)**을 피하기 위해 두 번째 형식(throw;)을 써야 해요.

try
{
    std::string("abc").substr(10); // std::out_of_range를 던짐
}
catch (const std::exception& e)
{
    std::cout << e.what() << '\n';
//  throw e; // std::exception 타입의 새 예외 객체를 복사 초기화함
    throw;   // std::out_of_range 타입의 예외 객체를 재던짐
}

throw 표현식은 타입 void의 prvalue 표현식으로 분류돼요. 다른 표현식과 마찬가지로 다른 표현식의 부분식이 될 수 있는데, 가장 흔한 예가 조건 연산자(conditional operator)예요.

double f(double d)
{
    return d > 1e7 ? throw std::overflow_error("too big") : d;
}

int main()
{
    try
    {
        std::cout << f(1e10) << '\n';
    }
    catch (const std::overflow_error& e)
    {
        std::cout << e.what() << '\n';
    }
}

키워드 (Keywords)

throw

예제 (Example)

생성자와 main이 함수 try 블록으로 예외를 처리하는 모습을 보여주는 코드예요.

#include <iostream>
#include <stdexcept>

struct A
{
    int n;

    A(int n = 0): n(n) { std::cout << "A(" << n << ") constructed successfully\n"; }
    ~A() { std::cout << "A(" << n << ") destroyed\n"; }
};

int foo()
{
    throw std::runtime_error("error");
}

struct B
{
    A a1, a2, a3;

    B() try : a1(1), a2(foo()), a3(3)
    {
        std::cout << "B constructed successfully\n";
    }
    catch(...)
    {
        std::cout << "B::B() exiting with exception\n";
    }

    ~B() { std::cout << "B destroyed\n"; }
};

struct C : A, B
{
    C() try
    {
        std::cout << "C::C() completed successfully\n";
    }
    catch(...)
    {
        std::cout << "C::C() exiting with exception\n";
    }

    ~C() { std::cout << "C destroyed\n"; }
};

int main () try
{
    // A 기저 하위 객체 생성
    // B의 a1 멤버 생성
    // B의 a2 멤버 생성에 실패
    // 되감기가 B의 a1 멤버를 파괴
    // 되감기가 A 기저 하위 객체를 파괴
    C c;
}
catch (const std::exception& e)
{
    std::cout << "main() failed to create C with: " << e.what();
}

출력:

A(0) constructed successfully
A(1) constructed successfully
A(1) destroyed
B::B() exiting with exception
A(0) destroyed
C::C() exiting with exception
main() failed to create C with: error

이 예제는 스택 되감기의 순서를 잘 보여줘요. foo()a2의 초기화 중 예외를 던지자, 이미 만들어진 a1이 파괴되고, 기저 클래스 A까지 되감기면서 파괴가 이어져요. 각 단계의 소멸자와 try 블록이 차례로 호출되는 흐름을 눈으로 확인할 수 있어요.

결함 보고 (Defect reports)

다음의 동작 변경 결함 보고가 이전에 공개된 C++ 표준들에 소급 적용됐어요.

DR 적용 공개된 동작 올바른 동작
CWG 499 C++98 경계가 알려지지 않은 배열은 타입이 불완전해 던질 수 없었지만, 감쇠된 포인터로부터는 아무 문제 없이 예외 객체를 만들 수 있었음 예외 객체에 타입 완전성 요구를 적용
CWG 668 C++98 지역 non-automatic 객체의 소멸자에서 예외가 던져져도 std::terminate가 호출되지 않았음 이 경우 std::terminate 호출
CWG 1863 C++11 던질 때 move-only 예외 객체에 복사 생성자가 요구되지 않았지만 나중에 복사는 허용됐음 복사 생성자 요구
CWG 1866 C++98 생성자로부터의 스택 되감기에서 변형 멤버가 누출됐음 변형 멤버 파괴
CWG 2176 C++98 지역 변수 소멸자에서의 throw가 반환 값 소멸자를 건너뛸 수 있었음 함수 반환 값을 되감기에 추가
CWG 2699 C++98 throw "EX"const char*가 아니라 사실상 char*를 던졌음 교정
CWG 2711 C++98 예외 객체의 복사 초기화 소스가 명시되지 않았음 표현식으로부터 복사 초기화
CWG 2775 C++98 예외 객체의 복사 초기화 요구가 불명확했음 명확히 함
CWG 2854 C++98 예외 객체의 저장 기간이 불명확했음 명확히 함
P1825R0 C++11 throw에서 매개변수로부터의 암시적 이동이 금지됐음 허용

참조 (References)

  • C++23 표준 (ISO/IEC 14882:2024): 7.6.18 [expr.throw], 14.2 [except.throw]
  • C++20 표준: 7.6.18 [expr.throw], 14.2 [except.throw]
  • C++17 표준: 8.17 [expr.throw], 18.1 [except.throw]
  • C++14 표준: 15.1 [except.throw]
  • C++11 표준: 15.1 [except.throw]
  • C++03 표준: 15.1 [except.throw]
  • C++98 표준: 15.1 [except.throw]

같이 보기 (See also)

  • 복사 생략(copy elision)
  • try 블록
  • 핸들러(handler)
  • noexcept 지정자
  • 예외 처리(Exception handling)
  • 동적 예외 명세 (C++17까지)

더 알아보기

  • try 블록: 던져진 예외를 잡아 처리하는 구문.
  • 예외 처리 (Exception handling): C++ 예외 메커니즘의 전체 구조.
  • noexcept 지정자 (C++11): 함수가 예외를 던지지 않음을 선언.