제 10 장: 예외

C 언어는 프로그램의 정상적인 흐름을 깨는 상황에 여러가지 방식으로 대응한다. C++에서도 이 모든 흐름-깨기 방법은 여전히 사용할 수 있다. 그렇지만 언급한 대안 중에서 setjmplongjmpC++ 프로그램에서 (심지어 C 프로그램에서도) 좀처럼 만나기 힘들다. 프로그램의 흐름이 완전히 뒤죽박죽되기 때문이다.

C++setjmplongjmp의 대안으로 예외를 제공한다. 예외로 C++ 프로그램은 통제된 영역을 벗어나 귀환할 수 있다. longjmpsetjmp의 단점을 겪을 필요가 없다.

예외는 함수 자체에서는 쉽게 처리가 불가능한 그러나 프로그램이 완전히 종료할 정도로 그렇게 재앙적인 것은 아닌 상황을 빠져 나오는 적절한 방법이다. 또, 예외는 범위가 작은 return과 거친 exit 사이에 유연한 제어 계층을 제공한다.

이 장은 예외를 다룬다. 먼저 다양한 예외와 setjmp/longjmp 조합이 프로그램에 미치는 충격에 관하여 예제를 하나 보여준다. 소프트웨어가 예외를 맞이할 때 제공해야 할 보장을 제시한다. 예외와 그 보장은 소멸자와 생성자에 중대한 의미가 있다. 그 중요성을 이 장을 마치면서 만나 보겠다.

10.1: 예외 구문

영역을 벗어나 goto를 처리하는 전통적인 C 방식의 예외와 비교하기 전에 먼저 예외 사용에 관련된 구문 요소를 소개하겠다.

10.2: 예외를 사용하는 예제

다음 예제에 똑같은 기본 프로그램을 사용한다. 프로그램은 OuterInner 두 개의 클래스가 있다.

먼저, Outer 객체는 main에 정의되고 Outer::fun 멤버가 호출된다. 다음, Outer::funInner 객체가 정의된다. Inner 객체를 정의한 후에 자신의 Inner::fun 멤버를 호출한다.

이상이다. Outer::fun 함수는 inner의 소멸자를 호출하면서 끝난다. 그러면 프로그램은 종료하면서 outer의 소멸자를 활성화한다. 다음이 그 기본 프로그램이다.

    #include <iostream>
    using namespace std;

    class Inner
    {
        public:
            Inner();
            ~Inner();
            void fun();
    };
    Inner::Inner()
    {
        cout << "Inner constructor\n";
    }
    Inner::~Inner()
    {
        cout << "Inner destructor\n";
    }
    void Inner::fun()
    {
        cout << "Inner fun\n";
    }

    class Outer
    {
        public:
            Outer();
            ~Outer();
            void fun();
    };
    Outer::Outer()
    {
        cout << "Outer constructor\n";
    }
    Outer::~Outer()
    {
        cout << "Outer destructor\n";
    }
    void Outer::fun()
    {
        Inner in;
        cout << "Outer fun\n";
        in.fun();
    }

    int main()
    {
        Outer out;
        out.fun();
    }

    /*
        출력:
    Outer constructor
    Inner constructor
    Outer fun
    Inner fun
    Inner destructor
    Outer destructor
    */

컴파일하고 실행하면 완전히 예상대로 출력된다. 소멸자가 올바른 순서로 호출된다 (생성자를 호출한 순서의 역순이다).

이제 두 가지 변형에 관심을 두어 보자. Inner::fun 함수 안에서 비-치명적 재앙 사건을 시연한다. 이 사건은 main이 끝날 때 처리하도록 되어 있다.

두 개의 변형을 연구해 보겠다. 첫 번째 변형은 setjmplongjmp가 사건을 처리하고 두 번째 변형은 C++의 예외 메커니즘으로 사건을 처리한다.

10.2.1: 구시대의 유물: `setjmp' 그리고 `longjmp'

이전 절의 기본 프로그램을 조금 바꾸었다. jmp_buf jmpBuf 변수를 포함해 setjmplongjmp를 사용하도록 만들었다.

Inner::fun 함수는 longjmp을 호출해 재앙적 사건을 시연하고 main이 종료할 때 쯤 처리한다. main에서 longjmp의 목표 위치는 setjmp 함수를 통하여 정의된다. Setjmp가 0을 돌려주면 jmp_buf 변수가 초기화되었다는 뜻이고 그러면 Outer::fun이 호출된다. 이 상황은 `정상 흐름'으로 표현된다.

프로그램의 반환 값은 Outer::fun 멤버가 정상적으로 끝났을 때만 0이다. 그렇지만 이런 일은 일어나지 않도록 설계되어 있다. Inner::fun 멤버는 longjmp 함수를 호출한다. 결과적으로 실행 흐름은 setjmp 함수로 돌아온다. 이 경우 반환 값으로 0을 돌려주지 않는다. 결과적으로 Outer::fun 멤버로부터 Inner::fun 멤버를 호출한 후에 mainif-서술문에 들어가고 프로그램은 반환 값 1을 가지고 종료한다. 다음 프로그램 소스를 연구할 때 이 처리 흐름을 따르도록 노력하라. 10.2절에 제시된 기본 프로그램을 직접 변경했다.

    #include <iostream>
    #include <setjmp.h>
    #include <cstdlib>

    using namespace std;

    jmp_buf jmpBuf;

    class Inner
    {
        public:
            Inner();
            ~Inner();
            void fun();
    };

    Inner::Inner()
    {
        cout << "Inner constructor\n";
    }
    void Inner::fun()
    {
        cout << "Inner fun\n";
        longjmp(jmpBuf, 0);
    }
    Inner::~Inner()
    {
        cout << "Inner destructor\n";
    }

    class Outer
    {
        public:
            Outer();
            ~Outer();
            void fun();
    };

    Outer::Outer()
    {
        cout << "Outer constructor\n";
    }
    Outer::~Outer()
    {
        cout << "Outer destructor\n";
    }
    void Outer::fun()
    {
        Inner in;
        cout << "Outer fun\n";
        in.fun();
    }

    int main()
    {
        Outer out;

        if (setjmp(jmpBuf) != 0)
            return 1;

        out.fun();
    }
    /*
        출력:
    Outer constructor
    Inner constructor
    Outer fun
    Inner fun
    Outer destructor
    */

이 프로그램의 출력은 inner의 소멸자가 호출되지 않는다는 것을 확실하게 보여준다. 이것은 longjmp가 직접 영역이 아닌 곳으로 건너뛴 결과이다. Inner::fun안의 longjmp 호출로부터 main안의 setjmp로 즉시 실행은 계속된다. 거기에서 반환 값은 0이 아니다. 프로그램은 반환 값을 1로 하여 종료한다. 영역이 아닌 곳으로 건너뛰기 때문에 Inner::~Inner는 절대로 실행되지 않는다. mainsetjmp에 돌아오면 기존의 스택은 망가져 버리므로 기다리고 있는 소멸자들을 모조리 무시해 버린다.

이 예제는 longjmpsetjmp를 사용하면 객체의 소멸자가 쉽게 무시될 수 있다는 사실을 보여준다. 그러므로 C++ 프로그램은 이 함수들을 절대로 사용하지 말아야 한다.

10.2.2: 예외: 좋은 대안

예외는 setjmplongjmp로 야기되는 문제들에 대한 C++의 해결책이다. 다음은 예외를 사용하는 예이다. 한 번 더 10.2절의 기본 프로그램을 활용한다.
    #include <iostream>
    using namespace std;

    class Inner
    {
        public:
            Inner();
            ~Inner();
            void fun();
    };
    Inner::Inner()
    {
        cout << "Inner constructor\n";
    }
    Inner::~Inner()
    {
        cout << "Inner destructor\n";
    }
    void Inner::fun()
    {
        cout << "Inner fun\n";
        throw 1;
        cout << "This statement is not executed\n";
    }

    class Outer
    {
        public:
            Outer();
            ~Outer();
            void fun();
    };

    Outer::Outer()
    {
        cout << "Outer constructor\n";
    }
    Outer::~Outer()
    {
        cout << "Outer destructor\n";
    }
    void Outer::fun()
    {
        Inner in;
        cout << "Outer fun\n";
        in.fun();
    }

    int main()
    {
        Outer out;
        try
        {
            out.fun();
        }
        catch (int x)
        {}
    }
    /*
        출력:
    Outer constructor
    Inner constructor
    Outer fun
    Inner fun
    Inner destructor
    Outer destructor
    */

Inner::fun은 이제 int 예외를 던진다. 이전에는 longjmp를 사용했었다. in.funout.fun이 호출하기 때문에 예외는 out.fun 호출을 둘러싸고 있는 try 블록 안에서 일어난다. int를 던졌기 때문에 이 값은 try 블록을 벗어나서 catch 절에 다시 나타난다.

Inner::fun 멤버는 longjmp 함수를 호출하는 대신에 예외를 던지면서 종료한다. 예외는 main에서 나포되고 프로그램은 종료한다. 이제 inner의 소멸자가 올바르게 호출되는 것을 볼 수 있다. 흥미로운 것은 Inner::fun의 실행이 실제로 throw 서술문에서 끝난다는 사실이다. throw 서술문 바로 다음에 놓여 있는 cout 서술문은 실행되지 않는다.

이 예제에서 무엇을 배울 수 있는가?

10.3: 예외 던지기

예외는 throw 서술문으로 던진다. throw 키워드 다음에 던질 예외 값을 정의하는 표현식을 준다. 다음은 그 예이다.
    throw "Hello world";        // char *를 던진다.
    throw 18;                   // int를 던진다.
    throw string("hello");      // string을 던진다.
지역 변수는 함수가 끝나면 존재하기를 멈춘다. 이것은 예외도 마찬가지다.

예외가 해당 함수를 떠나는 순간, 그 함수에 지역적으로 정의된 객체는 자동으로 파괴된다. 예외로 던져진 객체에도 마찬가지 일이 일어난다. 그렇지만 함수 문맥을 떠나기 바로 전에 객체가 복사된다. 그리고 마침내 적절한 catch 절에 도달하는 것은 바로 이 사본이다.

다음 예제는 이 과정을 보여준다. Object::fun는 지역적으로 Object toThrow를 정의한다. 이것이 예외로 던져진다. 예외는 main에서 나포된다. 그러나 그때 쯤이면 던져진 원래의 객체는 이미 더 이상 존재하지 않는다. main이 받은 것은 그 사본이다.

    #include <iostream>
    #include <string>
    using namespace std;

    class Object
    {
        string d_name;

        public:
            Object(string name)
            :
                d_name(name)
            {
                cout << "Constructor of " << d_name << "\n";
            }
            Object(Object const &other)
            :
                d_name(other.d_name + " (copy)")
            {
                cout << "Copy constructor for " << d_name << "\n";
            }
            ~Object()
            {
                cout << "Destructor of " << d_name << "\n";
            }
            void fun()
            {
                Object toThrow("'local object'");
                cout << "Calling fun of " << d_name << "\n";
                throw toThrow;
            }
            void hello()
            {
                cout << "Hello by " << d_name << "\n";
            }
    };

    int main()
    {
        Object out("'main object'");
        try
        {
            out.fun();
        }
        catch (Object o)
        {
            cout << "Caught exception\n";
            o.hello();
        }
    }

Object의 복사 생성자는 특별하다. 자신의 이름을 다른 객체의 이름으로 정의하기 때문이다. 그 이름에 문자열 " (copy)"가 추가된다. 이 덕분에 객체의 생성과 소멸을 더 자세하게 살펴볼 수 있다. Object::fun은 예외를 일으키고, 지역적으로 정의된 그의 객체를 던진다. 예외를 던지기 바로 전에 프로그램은 다음과 같이 출력한다.

    Constructor of 'main object'
    Constructor of 'local object'
    Calling fun of 'main object'
예외가 일어나면 다음 줄이 출력된다.
    Copy constructor for 'local object' (copy)
지역 객체는 throw에 건네지고 거기에서 값 인자로 취급되어 toThrow의 사본을 생성한다. 이 사본을 예외로 던지고 지역 toThrow 객체는 존재하기를 멈춘다. 이제 던져진 예외는 catch 절에서 잡는다. Object 값 매개변수를 정의하고 있다. 이것은 매개변수이기 때문에 또다른 사본이 만들어진다. 그래서 프로그램은 다음 텍스트를 출력한다.
    Destructor of 'local object'
    Copy constructor for 'local object' (copy) (copy)
catch 블록은 이제 다음과 같이 출력한다.
    Caught exception
이 다음, ohello 멤버가 호출된다. 실제로 toThrow 원본 객체의 사본의 사본을 받았음을 보여준다.
    Hello by 'local object' (copy) (copy)
다음으로 프로그램은 종료하고 나머지 객체는 이제 생성의 역순으로 파괴된다.
    Destructor of 'local object' (copy) (copy)
    Destructor of 'local object' (copy)
    Destructor of 'main object'

catch 절이 생성한 사본은 분명히 지나치다. catch 절 안에 참조 매개변수 객체를 정의하면 피할 수 있다. `catch (Object &o)'. 이제 프로그램은 다음과 같이 출력한다.

    Constructor of 'main object'
    Constructor of 'local object'
    Calling fun of 'main object'
    Copy constructor for 'local object' (copy)
    Destructor of 'local object'
    Caught exception
    Hello by 'local object' (copy)
    Destructor of 'local object' (copy)
    Destructor of 'main object'
toThrow 사본을 하나만 생성했다.

지역적으로 정의된 객체를 가리키는 포인터를 던지는 것은 나쁜 생각이다. 포인터는 던져지지만 포인터가 가리키는 객체는 예외가 던져지는 순간, 존재하기를 멈추기 때문이다. 실체가 없는 허상 포인터를 받는다. 좋지 않은 운명이다....

위에서 발견한 사실을 요약해 보자:

함수는 임무를 완수할 수 없으면 예외를 던진다. 그러나 프로그램은 여전히 계속 진행할 수 있다. 프로그램이 대화형 계산기라고 상상해 보자. 프로그램은 평가할 숫치 표현식을 기대한다. 표현식은 문법 에러가 있을 수 있거나 수학적으로 평가하기가 불가능할 수도 있다. 계산기에서 변수를 정의하고 사용할 수 있도록 허용할 수도 있다. 그리고 사용자는 존재하지 않는 값을 참조할 수도 있다. 엄청나게 많은 이유로 표현식 평가는 실패한다. 수 많은 이유로 예외가 던져진다. 이 중 어떤 것도 프로그램을 종료시키면 안 된다. 대신에 프로그램 사용자에게 문제의 성격을 알려주고 표현식을 좀 다르게 입력하도록 유도해야 한다. 예제:
    if (!parse(expressionBuffer))           // 파싱 실패
        throw "Syntax error in expression";

    if (!lookup(variableName))              // 변수가 발견되지 않음
        throw "Variable not defined";

    if (divisionByZero())                   // 나눗셈 불능
        throw "Division by zero is not defined";
throw 서술문들이 배치된 곳은 부적절하다. 프로그램 안쪽 깊숙히 내포되어 나타날 수도 있고 더 위의 수준에서 발견될 수도 있다. 게다가 던질 예외를 함수로 생성할 수도 있다. Exception 객체는 스트림-류의 삽입 연산을 지원할 수도 있어서 다음과 같은 일을 할 수 있다.
    if (!lookup(variableName))
        throw Exception() << "Undefined variable '" << variableName << "';

10.3.1: 빈 `throw' 서술문

던져진 예외를 조사할 필요가 가끔 있다. 예외 나포자는 예외를 조사한 후에 무시하거나 처리하거나 아니면 다시 던지기로 결정할 수 있다. 아니면 또다른 종류의 예외로 바꾸기로 결정할 수 있다. 예를 들어 서버-클라이언트 어플리케이션에서 클라이언트는 서버에게 요청을 큐에 넣어 제출한다. 요청마다 결국 서버가 응답하는 경우가 보통이다. 서버는 요청을 성공적으로 처리했음으로 응답하거나 어떤 에러가 일어났음을 알려서 응답한다. 아니면 서버가 죽었을 수도 있다. 클라이언트는 서버가 응답해 주기를 무한히 기다리지 말고 이 재앙을 발견할 수 있어야 한다.

이런 상황에 예외 처리자가 간접적으로 호출된다. 던져진 예외를 먼저 중간 수준에서 조사한다. 가능하면 거기에서 처리한다. 처리가 불가능하면 변경되지 않은 그대로 좀 바깥 수준으로 건네진다. 거기에서 그 곤란한 예외가 처리된다.

예외 처리자 코드에 throw 서술문을 배치하면 받은 예외는 특별한 유형의 예외를 처리할 수 있는 다음 수준으로 건네진다. 다시 던져진 예외는 절대로 이웃 예외 처리자에서 처리되지 않는다. 언제나 좀 더 바깥 수준에 있는 예외 처리자로 전송된다.

서버-클라이언트 문맥에서 다음 함수는

    initialExceptionHandler(string &exception)
string 예외를 처리하도록 설계할 수 있다. 받은 메시지를 조사한다. 간단한 메시지라면 그냥 처리한다. 그렇지 않으면 예외는 바깥 수준으로 건네진다. initialExceptionHandler의 구현에 빈 throw 서술문이 사용된다.
    void initialExceptionHandler(string &exception)
    {
        if (!plainMessage(exception))
            throw;

        handleTheMessage(exception);
    }
아래 10.5절에서 빈 throw 서술문을 사용하여 catch-블록에서 받은 예외를 던진다. 그러므로 initialExceptionHandler와 같은 함수는 다양한 유형으로 던져지는 예외를 처리할 수 있다. initialExceptionHandler의 문자열 매개변수에 유형이 일치하기만 하면 된다.

다음 예제는 약간 앞으로 건너 뛰어 제 14장에 다룰 주제들이다. 그렇지만 건너 뛰어도 흐름이 끊기지 않는다.

기본 예외 처리 클래스를 생성할 수 있다. 거기에서 특정한 예외 유형을 파생시킨다. Exception이라는 클래스가 있다고 해보자. ExceptionType Exception::severity 멤버 함수가 있다. 이 멤버 함수는 던져진 예외의 심각도를 알려준다 (당연하다!). Info, Notice, Warning, Error 또는 Fatal이 될 수 있다. 예외에 담긴 정보는 심각도에 따라 handle 함수가 처리한다. 게다가 모든 예외는 textMsg와 같은 멤버 함수를 지원한다. 예외에 관한 텍스트 정보를 string 안에 돌려준다.

다형적 handle 함수를 정의함으로써 기본 Exception 포인터나 참조로부터 호출하면 던져진 예외의 유형에 따라 다르게 행동하도록 만들 수 있다.

프로그램은 이 다섯 가지 유형 중 어떤 것이라도 던질 수 있다. MessageWarning 클래스가 Exception 바탕 클래스로부터 파생된다고 가정하면 예외 유형에 부합하는 handle 함수가 자동으로 다음 예외 나포자에 의하여 호출된다.

    //
    catch(Exception &ex)
    {
        cout << e.textMsg() << '\n';

        if
        (
            ex.severity() != ExceptionType::Warning
            &&
            ex.severity() != ExceptionType::Message
        )
            throw;              // 다른 예외를 건넨다.

        ex.handle();            // 메시지 또는 경고를 처리한다.
    }
이제 예외 처리자 앞에 있는 try 블록 어디에서든 Exception 객체나 그로부터 파생된 객체를 던질 수 있다. 던져진 객체는 모두 위의 처리자에게 나포된다. 예를 들어,
    throw Info();
    throw Warning();
    throw Notice();
    throw Error();
    throw Fatal();

10.4: try 블록

try-블록은 throw 서술문을 둘러싼다. 프로그램은 언제나 전역 try 블록에 둘러싸여 있고 그래서 throw 서술문은 코드 어디에든 나타날 수 있다는 사실을 유념하라. 그렇지만 throw 서술문이 함수 몸체에 사용되는 경우가 더 많다. 그런 함수는 try 블록 안에서 호출할 수 있다.

try 블록은 try 키워드와 복합 서술문으로 정의된다. 이어서 catch 절이 적어도 하나는 따라온다.

    try
    {
                // 여기에 할 일을 기술한다.
    }
    catch(...)  // catch 절이 적어도 하나는 있어야 한다.
    {}
Try-블록은 내포되어 예외 수준을 형성한다. 예를 들어 main의 코드는 try-블록으로 둘러싸여, 예외를 처리하는 바깥 수준을 형성한다. maintry-블록 안에서 함수가 호출된다. 그 안에 또 try-블록이 있고 다음 수준의 예외를 형성한다. 10.3.1항에서 보았듯이 안쪽 수준의 try-블록에 던져진 예외는 그 수준에서 처리될 수도 있고 안 될 수도 있다. 빈 throw 서술문을 예외 처리자에 배치하면 던져진 예외는 다음 (바깥) 수준으로 건네진다.

10.5: 예외 잡기

catch 절은 catch 키워드와 다음에 매개변수 하나를 정의한 매개변수 리스트가 따라온다. 여기에 특정한 catch 처리자가 잡을 예외의 유형과 (매개변수) 이름을 정의한다. 이 이름은 catch 절 다음에 오는 복합 서술문에서 변수로 사용될 수 있다. 예제:
    catch (string &message)
    {
        // 메시지를 처리할 코드
    }
원시 유형과 객체를 예외로 던질 수 있다. 지역 객체를 가리키는 참조나 포인터를 던지는 것은 나쁜 생각이다. 그러나 동적으로 할당된 객체를 가리키는 포인터는 던져도 된다. 예외 처리자가 할당된 메모리를 지워서 메모리 누수를 방지하기만 하면 된다. 그럼에도 그런 포인터를 던지는 것은 위험하다. 다음 예제에 보여주는 것처럼 동적으로 할당되지 않은 메모리와 동적으로 할당된 메모리를 예외 처리자가 구별하지 못할 수 있기 때문이다.
    try
    {
        static int x;
        int *xp = &x;

        if (condition1)
            throw xp;

        xp = new int(0);
        if (condition2)
            throw xp;
    }
    catch (int *ptr)
    {
        // ptr을 지울 것인가 말 것인가?
    }
예외 처리자의 매개변수 성격에 주의를 기울여야 한다. 동적으로 할당된 메모리를 가리키는 포인터가 예외로 던져질 때 처리자가 그 포인터를 처리한 후에 해당 메모리가 반납되는지 확인해야 한다. 포인터로 예외를 던지면 안된다. 동적으로 할당된 메모리를 포인터로 예외 처리자에게 건네야 한다면 unique_ptr이나 shared_ptr 같은 스마트 포인터로 싸서 건네야 한다 (18.3절18.4절).

여러 catch 처리자가 try 블록 다음에 따라 올 수 있다. 각 처리자는 자신만의 예외 유형을 정의하고 있다. 예외 처리자의 순서가 중요하다. 예외가 던져지면 그 유형에 제일 처음 부합하는 예외 처리자가 사용되며 나머지 예외 처리자는 무시된다. 결국 try-블록 다음에 기껏해야 예외 처리자 하나만 활성화된다. 보통 이것은 논란거리가 아니다. 각 예외마다 자신만의 독특한 유형이 있기 때문이다.

예를 들어 예외 처리자가 char *void *에 정의되어 있으면 NTB 문자열은 앞의 처리자가 잡는다. char *void *로도 간주할 수 있음을 눈여겨보라. 그러나 예외 유형을 부합시켜 보는 과정은 던져진 NTBS에 char * 처리자를 사용할 정도로 충분히 똑똑하다. 상응하는 유형의 예외를 잡으려면 유형에 아주 가깝도록 예외 처리자를 설계해야 한다. 예를 들어 int-예외는 double-나포자가 잡지 않는다. char-예외는 int-나포자가 잡지 않는다. 다음은 서로 계층적 관계가 없는 유형이라면 나포자의 순서가 중요하지 않음을 보여주는 작은 예제이다 (즉, intdouble로부터 파생되지 않는다. string은 NTBS로부터 파생되지 않는다):

#include <iostream>
using namespace std;

int main()
{
    while (true)
    {
        try
        {
            string s;
            cout << "Enter a,c,i,s for ascii-z, char, int, string "
                                                      "exception\n";
            getline(cin, s);
            switch (s[0])
            {
                case 'a':
                    throw "ascii-z";
                case 'c':
                    throw 'c';
                case 'i':
                    throw 12;
                case 's':
                    throw string();
            }
        }
        catch (string const &)
        {
            cout << "string caught\n";
        }
        catch (char const *)
        {
            cout << "ASCII-Z string caught\n";
        }
        catch (double)
        {
            cout << "isn't caught at all\n";
        }
        catch (int)
        {
            cout << "int caught\n";
        }
        catch (char)
        {
            cout << "char caught\n";
        }
    }
}

특정한 예외 처리자를 정의하는 대신에 특정한 클래스를 설계할 수 있다. 그의 객체는 예외에 관한 정보를 담는다. 이런 접근법은 앞서 10.3.1항에 언급했다. 이 접근법을 사용하면 처리자가 하나만 있으면 된다. 다른 유형의 예외는 모른다는 사실을 알기 때문이다.

    try
    {
        // 코드는 예외 포인터만 던진다.
    }
    catch (Exception &ex)
    {
        ex.handle();
    }

예외 처리 코드가 처리를 끝내면 실행은 마지막 예외 처리자 다음에 부합하는 try-블록에서 곧바로 계속된다 (기본 실행 흐름을 깨기 위하여 예외 처리자 자체는 (return이나 throw같은) 흐름 제어 서술문을 사용하지 않는다고 가정한다). 다음 사례들을 구별할 수 있다.

throw-서술문 다음의 try 블록 안에 있는 서술문은 모조리 무시된다. 그렇지만 throw 서술문을 실행하기 전에 try 블록 안에서 성공적으로 생성된 객체들은 예외 처리자 코드가 실행되기 전에 파괴된다.

10.5.1: 기본 예외 나포자

프로그램의 특정 수준에서 실제로 일정 갯수의 처리자만 있으면 된다. 몇 가지 유형의 예외만 처리하고 다른 모든 예외는 바깥 수준에 있는 try 블록의 예외 처리자에게 전달한다.

기본 예외 처리자를 사용하면 간접적으로 예외를 처리할 수 있다. 기본 예외 처리자는 (10.5절에 언급한 예외 나포자의 계통적 성질 때문에) 다른 모든 구체적인 예외 처리자 다음에 배치해야 한다.

이 기본 예외 나포자는 던져진 예외의 실제 유형을 알지 못하고 그 값도 알 수는 없지만 서술문은 실행할 수 있으므로 기본적인 처리를 할 수 있다. 게다가 나포된 예외는 소실되지 않는다. 그리고 기본 예외 처리자는 빈 throw 서술문을 사용하여 그 예외를 바깥 수준의 예외 처리자에게 되던질 수 있다 (10.3.1항). 거기에서 실제로 처리가 된다. 다음은 기본 예외 처리자를 이런 식으로 사용하는 법을 보여주는 예제이다.

    #include <iostream>
    using namespace std;

    int main()
    {
        try
        {
            try
            {
                throw 12.25;    // 배정도 수 전용의 처리자가 없음
            }
            catch (int value)
            {
                cout << "Inner level: caught int\n";
            }
            catch (...)
            {
                cout << "Inner level: generic handling of exceptions\n";
                throw;
            }
        }
        catch(double d)
        {
            cout << "Outer level may use the thrown double: " << d << '\n';
        }
    }
    /*
        출력:
    Inner level: generic handling of exceptions
    Outer level may use the thrown double: 12.25
    */

프로그램의 출력은 기본 예외 처리자 안의 빈 throw 서술문이 받은 예외를 다음 (바깥) 수준의 예외 나포자에게 던지는 것을 보여준다. 던져진 예외의 유형과 값을 그대로 보존하면서 말이다.

그리하여 내부 수준에서 기본적으로 또는 총칭적으로 예외를 처리할 수 있다. 아니면 바깥 수준에서 예외의 유형에 기반하여 구체적으로 처리할 수 있다. 덧붙여서, 특히 멀티-쓰레드 프로그램에서 (제 20 장) 던져진 예외는 std::exception 객체를 std::exception_ptr 객체로 변환하면 쓰레드 사이에 전달할 수 있다. 이 절차는 기본 나포자 안에서 사용할 수 있다. std::exception_ptr 클래스의 사용 범위는 20.13.1항을 참고하라.

10.6: 예외 투포 리스트 선언하기 (비추천)

함수가 정의되면 다른 함수로부터 호출된다. 호출된 함수가 호출 함수와 같은 소스 파일에 정의되어 있지 않으면 피호출 함수가 선언되어 있어야 한다. 이런 목적에 헤더 파일이 주로 사용된다.

그렇게 호출된 함수는 예외를 던질 가능성이 있다. 그런 함수의 선언에 함수 투포 리스트 또는 예외 지정 리스트를 담을 수 있다 (이제는 비추천이다. 23.7절). 함수가 던질 수 있는 예외의 유형을 지정한다. 예를 들어 `char *'와 `int' 예외를 던질 수 있는 함수는 다음과 같이 선언할 수 있다.

    void exceptionThrower() throw(char *, int);
함수 투포 리스트는 함수 헤더 다음에 바로 따라온다 (또 다음에 const 지정자가 올 수 있다). 투포 리스트는 비어 있을 수 있으며 유형마다 쉼표로 분리하여 지정한다. 일반적인 구문은 다음과 같다.
    throw ()
    throw (type)
    throw (type1, type2, type3 ...)
여기에서 생략기호는 갯수에 상관없이 쉼표로 분리된 유형이 지정된다는 뜻이다.

함수가 예외를 던지지 않을 것이라고 보장하려면 빈 함수 투포 리스트를 사용할 수도 있다. 예를 들어,

    void noExceptions() throw ();
함수 정의에 사용된 함수 헤더는 선언에 사용된 함수 헤더에 정확하게 일치해야 한다. 물론 빈 함수 투포 리스트도 여기에 포함된다.

함수 투포 리스트가 지정된 함수는 투포 리스트에 언급된 유형의 예외만 던질 수 있다. 다른 유형의 예외를 던지면 실행 시간 에러가 일어난다. 예를 들어 아래에 보이는 charPintThrower 함수는 확실히 char const * 예외를 던진다. intThrowerint 예외를 던질 수도 있기 때문에 charPintThrower의 함수 투포 리스트에도 역시 int가 담겨 있어야 한다.

    #include <iostream>
    using namespace std;

    void charPintThrower() throw(char const *, int);

    class Thrower
    {
        public:
            void intThrower(int) const throw(int);
    };

    void Thrower::intThrower(int x) const throw(int)
    {
        if (x)
            throw x;
    }

    void charPintThrower() throw(char const *, int)
    {
        int x;

        cerr << "Enter an int: ";
        cin >> x;

        Thrower().intThrower(x);
        throw "this text is thrown if 0 was entered";
    }

    void runTimeError() throw(int)
    {
        throw 12.5;
    }

    int main()
    {
        try
        {
             charPintThrower();
        }
        catch (char const *message)
        {
            cerr << "Text exception: " << message << '\n';
        }
        catch (int value)
        {
            cerr << "Int exception: " << value << '\n';
        }
        try
        {
            cerr << "Generating a run-time error\n";
            runTimeError();
        }
        catch(...)
        {
            cerr << "not reached\n";
        }
    }

투포 리스트가 없는 함수는 종류에 상관없이 예외를 던질 수 있다. 투포 리스트가 없다면 프로그램의 설계자는 올바른 처리자를 제공할 책임이 있다.

다양한 이유로 예외 투포자를 선언하는 것은 이제 비추천이다. 예외 투포자를 선언하더라도 부적절한 예외가 던져졌는지 컴파일러가 점검해 주지 않기 때문이다. 그 보다는 던져진 실제 예외를 처리하는 추가 코드로 그 함수가 둘러싸일 것이다. 거기에서 실제로 던져진 예외를 점검한다. 해당 예외가 함수 투포 리스트 안에 있는 유형이라면 그 예외를 되던진다. 그렇지 않으면 실행 시간 에러가 던져진다. 컴파일 시간 점검 부담 대신에 실행 시간 부담이 있다. 결과적으로 그 함수의 코드에 (실행 시간) 코드가 추가된다. 예를 들어 다음과 같이 작성할 수는 있다.

    void fun() throw (int)
    {
        // 이 함수의 코드. 예외를 던짐
    }
그러나 함수는 다음과 같은 형태로 컴파일될 것이다 (함수 헤더 바로 다음의 try의 사용법은 10.11절 참조 그리고 bad_exception의 설명은 10.8절 참고):
    void fun()
    try         // 이 코드는 throw(int)의 결과임
    {
                // 함수의 코드, 모든 종류의 예외를 던진다.
    }
    catch (int) // throw(int)의 결과인 나머지 코드
    {
        throw;  // `해당' 처리자가 잡을 수 있도록
                // 예외를 되던진다.
    }
    catch (...) // 다른 예외를 모두 잡는다.
    {
        throw bad_exception{};
    }
던지고 받는 예외의 횟수가 배로 늘어나기 때문에 실행 시간 부담이 가중된다. 투포 리스트가 없다면 던져진 int 예외는 단순히 해당 처리자가 잡기만 하면 된다. 투포 리스트가 있다면 int 예외는 먼저 함수에 추가된 `safeguarding' 처리자가 잡는다. 거기에서 해당 처리자가 잡도록 그 예외를 되던진다.

10.6.1: noexcept

함수 투포 리스트는 비추천이 되었지만 그 보다 더 젊은 사촌인 noexcept는 그렇지 않다. noexcept 키워드는 이전에 빈 함수 투포 리스트가 사용되던 곳에 사용된다 (10.9절에서 noexcept가 사용되는 예제를 참고하라). 빈 함수 투포 리스트와 마찬가지로 실행 시간에 부담이 좀 있지만 규칙 위반이 보일 때 빈 함수 투포 리스트보다 noexcept가 더 엄격하다. 함수 투포 리스트 규칙을 위반하면 std::unexpected 예외가 던져진다. noexcept를 위반하면 std::terminate가 실행되고 결과적으로 프로그램이 끝난다.

그리고 noexcept에 인자를 부여해 컴파일 시간에 평가할 수 있다. true로 평가되면 noexcept 요구 조건이 사용된다. 평가가 false를 돌려주면 noexcept 요구 조건은 무시된다. noexcept의 고급 사용법은 23.7절에 다룬다.

10.7: iostream 그리고 예외

예외가 C++에 도입되기 전에 C++ I/O 라이브러리가 널리 사용되었다. 그래서 iostream 라이브러리의 클래스는 예외를 던지지 못한다. 그렇지만 ios::exceptions 멤버 함수를 사용하면 그 행위를 바꿀 수 있다. 이 함수는 중복정의 버전이 두 가지 있다. I/O 라이브러리에서 예외는 ios::exception으로부터 파생된 ios::failure 클래스의 객체이다. failure 객체를 정의할 때 std::string const &message를 지정할 수 있다. 그러면 그 메시지는 virtual char const *what() const 멤버를 사용하여 열람할 수 있다.

예외는 예외적인 상황에 사용해야 한다. 그러므로 EOF처럼 정상적인 상황에 스트림 객체가 예외를 던지도록 만드는 것은 문제가 있다고 생각한다. (입력 에러가 일어나면 안 되는 상황에 그리고 부패된 파일을 뜻하는 상황에) 예외를 사용하여 입력 에러를 처리하는 것은 나무랄 것이 없다. 그러나 적절한 에러 메시지를 가지고 프로그램을 종료하는 것이 더 합당한 조치일 것이다. 다음의 상호대화 프로그램을 연구해 보자. 예외를 사용하여 부정확한 예외를 잡는다.

    #include <iostream>
    #include <climits>
    using namespace::std;

    int main()
    {
        cin.exceptions(ios::failbit);   // 실패하면 예외를 던진다.
        while (true)
        {
            try
            {
                cout << "enter a number: ";
                int value;
                cin >> value;
                cout << "you entered " << value << '\n';
            }
            catch (ios::failure const &problem)
            {
                cout << problem.what() << '\n';
                cin.clear();
                cin.ignore(INT_MAX, '\n');  // 결함 있는 줄을 무시한다.
            }
        }
    }

기본값으로 ostream 객체 안에서 일어난 예외는 이 객체들이 잡는다. 결과로 ios::badbit 깃발이 올라간다. 이 문제에 관한 연구는 14.8절을 참고하라.

10.8: 표준 예외

데이터 유형에 상관없이 모두 예외로 던질 수 있다. 여러 예외 클래스가 이제 C++ 표준에 추가로 정의되어 있다. 그런 추가 예외 클래스를 사용하려면 먼저 <stdexcept> 헤더를 포함해야 한다. 이 모든 표준 예외는 그 자체가 클래스 유형일 뿐만 아니라 std::exception 클래스의 모든 편의기능을 제공한다. 표준 예외 클래스의 객체들은 std::exception 클래스의 객체로 간주하면 된다.

std::exception 클래스는 다음 멤버를 제공한다.

    char const *what() const;
예외의 본성을 짧은 텍스트 메시지로 기술한다.

C++는 다음의 표준 예외 클래스를 정의한다.

모든 예외 클래스는 std::exception으로부터 파생된다. 이 모든 추가 클래스의 생성자는 std::string const & 인자를 받는다. 이 인자는 예외의 이유를 요약하고 있다 (exception::what 멤버로 열람한다). 추가로 정의된 예외 클래스는 다음과 같다.

10.8.1: 표준 예외: 사용할 것인가 말 것인가?

유형에 상관없이 어떤 값이든 예외로 던질 수 있기 때문에 언제 표준 예외 유형의 값을 던져야 할지 (있다면) 언제 다른 유형의 값을 던져야 할지 궁금할 것이다.

C++ 공동체에서 현재의 관례는 예외적인 상황에서만 예외를 던지는 것이다. 그런 점에서 예외 사용에 관한 C++의 철학은 다른 언어의 세계에서 사용되는 예외 처리 방식과 현저하게 다르다. 예를 들어 자바라면 예외인 상황에서도 C++는 예외로 간주하지 않는 경우가 있다. 또다른 일반적인 관례는 소프트웨어를 설계할 때 `개념적인' 스타일을 따르는 것이다. 멋진 예외 사례는 소스의 어느 시점에서 예외를 던져서 무슨 일이 일어나는지 보여줄 수 있다는 것이다. std::out_of_range 예외를 던져주면 소프트웨어 유지라는 관점에서는 좋다. 그 즉시 예외의 이유를 인지할 수 있기 때문이다.

catch-절에서 의미구조적 문맥은 더 이상 적절하지 않게 된다. std::exception 예외를 잡아서 what()으로 내용을 보여줌으로써 사용자에게 무슨 일이 일어났는지 알려준다.

그러나 다른 유형의 값을 던지는 것도 역시 유용할 수 있다. 예외를 던지고 좀 낮은 수준에서 그것을 잡고 싶은 상황이라면 어떨까? 그 사이에 프로그래머가 통제하지 못하는 외부 소프트웨어 라이브러리를 따라 다양한 수준을 넘나들 수도 있다. 그런 상황에 예외가 일어날 수 있다. 그리고 그런 예외를 라이브러리 코드에서 잡아 버릴 수도 있다. 표준 예외 유형을 던질 때 그 예외가 외부 라이브러리에서 잡혀버리지 않는다고 확신하기가 힘들다. 모두-잡기가 (즉, catch (...)가) 사용되지 않았다면 std::exception 가족으로부터 예외를 던지는 것은 별로 좋은 생각이 아닐 수 있다. 그렇다면 그냥 비어 있는 enum으로부터 값을 던지면 잘 작동한다.

    enum HorribleEvent 
    {};      

    ... 깊은 수준에서:
        throw HorribleEvent{};

    ... 얕은 수준에서:
    catch (HorribleEvent hs)
    {
        ...
    }
다른 예제들은 쉽게 발견할 수 있다. 메시지와 에러 (종료) 코드를 보유한 클래스를 하나 설계하라. 필요하면 그 클래스의 객체를 던지고, main 함수의 try 블록 catch 절에서 잡아라. 그러면 그 사이에 정의된 객체들이 모두 산뜻하게 소멸된다고 확신할 수 있다. 마지막으로 비-예외 객체에 내장된 에러 메시지를 보여주고 종료 코드를 돌려주면 끝이다.

그래서 가능하면 std::exception 유형을 사용하고 필요한 작업을 깔끔하게 마치기를 권고한다. 그러나 그저 불쾌한 상황을 모면하기 위해 예외를 사용하거나 또는 외부에 제공된 코드가 std::exception 예외를 잡을 가능성이 있다면 다른 유형의 값이나 객체를 던지는 것을 고려해 보라.

10.9: system_error 객체

std::system_error 클래스는 std::runtime_error 클래스로부터 파생된다.

system_error 클래스와 그 관련 클래스를 사용하기 전에 <system_error> 헤더를 포함해야 한다.

(시스템) 에러 값에 연관된 에러를 만나면 system_error 객체를 던질 수 있다. 그런 에러는 전형적으로 (운영 체제 수준의) 낮은-수준의 함수에 연관되어 있다. 그러나 (사용자의 나쁜 입력이나 존재하지 않는 요구처럼) 다른 유형의 에러도 처리할 수 있다.

에러 코드 객체에 에러 값과 그에 부합하는 범주가 저장된다. 범주는 에러 코드가 속하는 구역을 정의한다. 사실상 이것은 열거체가 일련의 에러 코드에 연관되고 범주가 그 열거체에 연관된다는 뜻이다. 새로 열거체와 범주를 정의할 수 있다. 서로 다른 열거체 사이에 열거 심볼과 열거 값은 동일할 수 있다. 혼란을 피하기 위해 마치 이름공간처럼 범주를 사용할 수 있다. 범주를 사용하는 이유 한 가지는 열거체가 상속을 지원하지 않기 때문이다. error_code 객체 안에 열거체가 int 값으로 저장되어 있고, 원래의 열거 클래스는 잃어 버린다.

에러 코드와 에러 범주 말고도 에러 조건이 더 있다. 에러 조건은 `높은 수준의' 에러 원인에 연관된다. 사용자 입력이 나쁘거나 시스템 함수가 실패하거나 또는 존재하지 않은 요구를 할 경우에 에러가 일어난다. 에러 조건은 또한 플랫폼에 독립적이다. 예를 들어 사용자가 이런 저런 플랫폼에서 엉터리로 입력을 하더라도 에러 코드와 에러 범주는 특정한 프로그램과 라이브러리 함수에 맞게 재단된다.

system_error 객체를 생성할 때 error_code 클래스와 error_category 클래스를 지정할 수 있다. 뒤의 두 클래스와 더불어 error_condition 클래스를 소개한다. 그 다음에 system_error 클래스를 더 자세하게 다룬다.

그림 8은 다양한 구성요소들이 서로 어떻게 작용하는지 보여준다.


그림 8: system_error: 관련 구성요소들

system_error는 결국exception으로부터 파생되기 때문에 표준 what 멤버가 제공된다. 두 번째 데이터 요소는 error_code이다. error_codeint 값으로 구성할 수 있다. 별도로 정의된 에러 코드 열거 값으로부터도 구성할 수 있다. error_codeerror_categorie에 연관되어 있기 때문에 int 에러 값을 사용할 수 있으면 부합하는 error_category를 제공해야 한다.

POSIX 시스템에서 errno 변수는 수 많은, 종종 좀 비밀스러운, 심볼에 연관되어 있다. 대신에 미리 정의된 enum class errc는 직관적으로 더 매력적인 심볼을 사용하려고 시도한다. 그 심볼들은 강력한 유형의 열거체에 정의되기 때문에 부합하는 error_code를 정의할 때 직접적으로 사용할 수 없다. 대신에 make_error_code 함수를 사용할 수 있다. 이 함수는 enum class errc 값을 error_code 객체로 변환한다.

큰 개요는 보여주었으므로 그림 8에 보여준 다양한 구성요소들을 더 가까이 살펴 볼 시간이다.

10.9.1: `std::error_code' 클래스

error_code 객체는 error_categorysystem_error가 사용한다. 예를 들어 system_error 생성자는 std::error_code 객체를 받는다.

error_code 객체의 주 목적은 에러 값과 관련된 에러 범주를 캡슐화하는 것이다. 종종 에러 값은 전역 int errno에 할당된다.

error_code 클래스는 다음 공개 인터페이스와 자유 함수를 선언한다.

생성자:

멤버:

자유 함수:

std 이름공간에 정의된 enum class errc에 정의된 심볼의 값들은 C 마크로가 사용하는 전통적인 에러 코드 값과 같다. 그러나 그의 값은 약간 더 명확하게 에러 조건을 기술한다. 예를 들어,

    enum class errc 
    {
        address_family_not_supported, // EAFNOSUPPORT
        address_in_use,               // EADDRINUSE
        address_not_available,        // EADDRNOTAVAIL
        already_connected,            // EISCONN
        argument_list_too_long,       // E2BIG
        argument_out_of_domain,       // EDOM
        bad_address,                  // EFAULT
        ...
    };

강력하게 유형이 정의되는 다른 열거체도 몇 가지가 있다. 예를 들어 enum class future_errc에는 (20.9절) 멀티 쓰레드의 문맥에 사용되는 에러 심볼이 정의되어 있다. 자신만의 에러 코드 열거체를 정의하는 법은 23.6.3항에 다룬다.

10.9.2: `std::error_category' 클래스

std::error_category 바탕 클래스로부터 파생된 클래스의 실체들은 (14.9절) 에러 코드 그룹을 식별한다. 새로운 에러 범주는 새로운 에러 코드 열거체에 부합하도록 정의된다.

std::error_category으로부터 error_category 클래스가 새로 파생된다. 그 다음에 system_error 생성자가 사용할 수 있다.

에러 범주는 싱글턴(singleton)으로 설계된다. 클래스마다 실체가 하나만 존재해야 한다. 싱글턴을 사용함으로써 error_category 객체가 같은지 주소를 보고 손쉽게 추론할 수 있다.

error_category 클래스는 여러 멤버를 제공한다. 대부분은 가상(virtual)으로 선언된다 (제 14장). 우리가 직접 설계할 에러 범주 클래스에 그런 멤버들을 정의할 수 있다는 뜻이다 (23.6.3항). 다음 멤버들이 std::error_category에 선언되어 있다:

미리 정의된 에러 범주를 돌려주는 함수는 다음과 같다.

10.9.3: `std::error_condition 클래스

error_condition 객체는 에러의 원인에 관한 정보를 저장한다. 예를 들어 사용자가 입력을 엉터리로 하거나 접근 권한이 없거나 시스템 함수가 실패하면 에러가 일어난다. 그런 에러의 원인은 특정한 플랫폼에 국한되지 않는다. 그래서 플랫폼에 독립적인 범주를 나타낸다.

에러 조건 객체는 error_codeerror_category 클래스의default_error_condition 멤버가 돌려준다. 이 객체는 error_category::equivalent 멤버가 인자로 기대한다.

error_condition 클래스는 다음 공개 인터페이스를 선언한다.

생성자:

멤버:

두 개의 error_condition 객체가 서로 같은지 비교할 수 있고 operator<를 사용하여 정렬할 수 있다.

10.9.4: system_error 클래스

system_error 객체는 error_codes나 에러 값 (ints) 또는 부합하는 에러 범주 객체로부터 구성할 수 있다. 일어난 에러의 본성을 표준적으로 기술하는 텍스트가 선택적으로 따라온다.

다음은 system_error 클래스의 공개 인터페이스이다:

class system_error: public runtime_error 
{
    public:
        system_error(error_code ec);
        system_error(error_code ec, string const &what_arg);
        system_error(error_code ec, char const *what_arg);

        system_error(int ev, error_category const &ecat);
        system_error(int ev, error_category const &ecat,
                         char const *what_arg);
        system_error(int ev, error_category const &ecat,
                         string const &what_arg);

        error_code const &code() const noexcept;
        char const *what() const noexcept;
}
ev 값은 종종 errno 변수의 값과 같다. chmod(2)와 같은 시스템 수준의 함수가 실패할 때 설정된다.

인터페이스 앞쪽부터 세 개의 생성자는 error_code 객체를 첫 인자로 받는다. error_code 생성자 중 하나가 interror_category를 인자로 기대하기 때문에, 두 번째 집합인 세 개의 생성자도 첫 번째 생성자 집합 대신에 사용할 수 있다. 예를 들어,

    system_error(errno, system_category(), "context of the error");
    // 다음과 동일:
    system_error(error_code(errno, system_category()), 
                                            "context of the error");
두 번째 집합 세 개의 생성자는 기존의 함수가 이미 error_code를 돌려줄 때 주로 사용된다. 예를 들어,
    system_error(make_error_code(errc::bad_address), 
                                            "context of the error");
    // 아니면 다음도 가능함:
    system_error(make_error_code(static_cast<errc>(errno)), 
                                            "context of the error");

표준 what 멤버 외에도 system_error 클래스는 또 code 멤버를 제공한다. 예외의 에러 코드에 대한 상수 참조를 돌려준다.

what 멤버가 돌려준 NTBS는 다음과 같이 system_error 객체로 형식화할 수 있다:

    what_arg + ": " + code().message()

주의하라. system_errorruntime_error으로부터 파생되었지만 std::exception 객체를 받을 때 code 멤버를 잃어 버리게 될 것이다. 물론 하향 형변환을 할 수는 있지만 그것은 임시 방편에 불과하다. 그러므로 system_error가 던져지면 부합하는 catch(system_error const &) 절을 제공해 code 멤버가 돌려주는 값을 열람해야 한다. 이 때문에 그리고 system_error를 사용할 때 관련된 클래스가 복잡하게 조직되어 있기 때문에 일반적으로 예외를 처리하기가 몹시 복잡하고 어렵게 된다. 그러나 복잡성을 대가로 하여 편리하게 intenum 에러 값을 범주화할 수 있다. 관련된 복잡성을 제 23장에 더 자세하게 다룬다. 특히 23.6.3항23.6.5항을 참고하라 (유연한 다른 방법은 필자의 Bobcat 라이브러리에서 FBB::Exception 클래스를 참고하라. )

10.10: 예외 보장

소프트웨어는 예외에 안전해야 한다. 프로그램은 예외에 직면하면 지정된 절차에 맞게 작동을 계속해야 한다. 예외 안전성을 언제나 쉽게 실현할 수 있는 것은 아니다. 이 절은 예외 안전성을 연구하면서 그의 지침과 용어들을 소개한다.

예외는 어떤 C++ 함수에서든 일어날 가능성이 있다. 이 모든 예외 상황을 직접적으로 그리고 직관적으로 알 수는 없다. 다음 함수를 연구해 보고 어느 지점에서 예외가 일어날 지 생각해 보자:

    void fun()
    {
        X x;
        cout << x;
        X *xp = new X(x);
        cout << (x + *xp);
        delete xp;
    }
위와 같이 사용되는 cout은 예외를 던지지 않는다고 간주할 수 있을지라도 적어도 13 가지의 상황에 예외가 던져질 가능성이 있다. 여기에서 강조하고 싶은 게 있다 (10.12절에 더 깊이 연구한다). 예외가 소멸자를 떠날 수는 있지만 그것은 C++ 표준을 범하는 것이다. 그래서 예의가 있는 C++ 프로그램이라면 이 표준을 지켜야 한다.

이 많은 상황에 예외를 던질지도 모르는데 어떻게 작동하는 프로그램을 만들 수 있다고 기대할 수 있을까?

예외는 엄청나게 많은 상황에 일어날 수 있다. 그러나 심각한 문제는 적어도 다음 예외 보장 중 하나만 제공할 수 있으면 방지된다.

10.10.1: 기본 예외 보장

기본 보장은 할당된 과업을 완수하지 못한 함수에게 끝내기 전에 할당된 모든 자원을 (보통은 메모리를) 반납하라고 명령한다. 실질적으로 모든 함수와 연산자는 예외를 던질 가능성이 있고 함수는 반복적으로 자원을 할당할 가능성이 있기 때문에 자원을 할당하는 함수의 사본은 아래에 보여주듯이 try 블록을 정의해 던져지는 예외를 모두 잡는다. catch 처리자의 과업은 할당된 자원을 모두 돌려준 다음, 다시 그 예외를 되던지는 것이다.
    void allocator(X **xDest, Y **yDest)
    {
        X *xp = 0;              // 예외를 던지지 않는 것으로 시작
        Y *yp = 0;

        try                     // 이 부분은 예외를 던질 가능성이 있다.
        {
            xp = new X[nX];     // 다른 방법으로 객체 할당
            yp = new Y[nY];
        }
        catch(...)
        {
            delete xp;
            throw;
        }

        delete[] *xDest;        // 예외를 던지지 않는 것으로 종료
        *xDest = xp;
        delete[] *yDest;
        *yDest = yp;
    }
try 이전 코드에서 new 연산자 호출로 반환된 주소를 받는 포인터는 0으로 초기화된다. 나포 처리자는 배당된 메모리를 모두 돌려줄 수 있어야 하기 때문에 try 블록 밖에 있어야 한다. 할당에 성공하면 목표 포인터가 가리키는 메모리가 반환된 후에 그 포인터에 새 값이 주어진다.

배당과 초기화는 실패할 수 있다. 배당하지 못하면 newstd::bad_alloc 예외를 던지고 나포 처리자는 그냥 0 포인터를 삭제한다. 그것으로 OK이다.

배당에 성공하지만 객체를 생성하지 못하면 예외를 던지는데 확실하게 다음과 같은 일이 일어난다.

결론적으로 new가 실패하더라도 메모리 누수는 없다. 위의 try 블록 안에서 new X는 실패할 수 있다. 그래도 0-포인터에는 영향을 미치지 않는다. 그래서 나포 처리자는 그냥 0 포인터를 삭제하기만 하면 된다. new Y가 실패하면 xp는 할당된 메모리를 가리킨다. 그래서 공용 풀에 돌려 주어야 한다. 이런 일은 나포 처리자 안에서 일어난다. new Y가 적절하게 완료되면 마지막 포인터는 (즉, yp는) 0이 아니게 된다. 그래서 나포 처리자는 yp가 가리키는 메모리를 돌려줄 필요가 없다.

10.10.2: 강력 보장

강력 보장은 예외에 직면하여 객체의 상태가 바뀌면 안된다고 명령한다. 이것은 예외를 던질 가능성이 있는 모든 연산을 별도의 데이터 사본에 수행함으로써 실현된다. 이 모든 것이 성공하면 현재 객체와 (이제 변경에 성공한) 그의 사본이 교체된다. 이 접근법의 예는 표준적인 중복정의 할당 연산자에서 볼 수 있다.
    Class &operator=(Class const &other)
    {
        Class tmp(other);
        swap(tmp);
        return *this;
    }
복사 생성은 예외를 던질 수 있지만 이 방법은 현재 객체의 상태를 건드리지 않은 채로 유지해 준다. 사본 생성에 성공하면 swap은 현재 객체의 내용과 tmp의 내용을 바꾸고 현재 객체의 참조를 돌려준다. 이 방법이 성공하려면 swap이 예외를 던지지 않을 것이라는 보장을 해야 한다. 참조를 (또는 원시 유형의 값을) 돌려주는 것은 예외를 던지지 않는다고 보장할 수 있다. 그러므로 중복정의 할당 연산자의 표준적 형태는 강력 보장의 요구 조건을 만족한다.

강력 보장과 관련하여 몇 가지 규칙이 정해졌다 (Sutter, H., Exceptional C++, Addison-Wesley, 2000). 예를 들어,

표준 할당 연산자는 첫 번째 규칙에 맞는 좋은 예이다. 또다른 예는 객체를 저장하는 클래스에서 볼 수 있다. 여러 Person 객체를 저장하는 PersonDb 클래스를 연구해 보자. 이 클래스에 void add(Person const &next) 멤버를 구현해 보자. 이 함수의 평범한 구현은 다음과 같을 것이다 (그저 첫 번째 규칙이 적용되는 것을 보여주기 위한 의도이다. 그 목적 말고는 효율성의 측면을 완전히 무시한다.).

    void PersonDb::newAppend(Person const &next)
    {
        Person *tmp = 0;
        try
        {
            tmp = new Person[d_size + 1];
            for (size_t idx = 0; idx < d_size; ++idx)
                tmp[idx] = d_data[idx];
            tmp[d_size] = next;
        }
        catch (...)
        {
            delete[] tmp;
            throw;
        }
    }

    void PersonDb::add(Person const &next)
    {
        Person *tmp = newAppend(next);
        delete[] d_data;
        d_data = tmp;
        ++d_size;
    }
(비공개) newAppend 멤버의 과업은 다음 Person 객체의 데이터를 포함하여 현재 할당된 Person 객체의 사본을 만드는 것이다. 그리고 catch 처리자는 복사 또는 할당중에 던져지는 모든 예외를 잡는다. 지금까지 할당된 메모리를 모두 돌려주고, 마지막으로 그 예외를 되던진다. 이 함수는 예외를 독점하지 않는다. 모든 예외를 호출자에게 전파하기 때문이다. 이 함수는 또한 PersonDb 객체의 데이터를 변경하지도 않는다. 그래서 예외 강력 보장을 만족한다. newAppend으로부터 돌아오면 add 멤버는 이제 그의 데이터를 변경할 수 있다. 기존의 데이터는 반환되고 d_data 포인터는 새로 생성된 Person 객체의 배열을 가리킨다. 마지막으로 d_size가 증가한다. 이 세 단계는 예외를 던지지 않으므로 add도 역시 강력 보장을 만족한다.

두 번째 제일 규칙은 PersonDb::erase(size_t idx) 멤버를 사용하여 보여줄 수 있다 (객체의 데이터를 변경하는 함수는 (포함된) 원래의 객체를 값으로 돌려주면 안된다). 다음은 원래의 d_data[idx] 객체를 돌려주기 위한 구현이다.

    Person PersonData::erase(size_t idx)
    {
        if (idx >= d_size)
            throw string("Array bounds exceeded");
        Person ret(d_data[idx]);
        Person *tmp = copyAllBut(idx);
        delete[] d_data;
        d_data = tmp;
        --d_size;
        return ret;
    }
복사 생략은 ret를 돌려줄 때 복사 생성자의 사용을 막는다. 하지만 일어나지 않는다는 보장은 없다. 게다가, 복사 생성자는 예외를 던질 가능성이 있다. 예외가 던져지면 함수는 PersonDb의 데이터를 회복할 수 없을 정도로 뭉개 버리기 때문에 강력 보장을 할 수 없다.

값으로 d_data[idx]를 돌려주지 말고 외부에 Person 객체를 할당한 다음에 PersonDb의 데이터에 손대는 것이 좋다.

    void PersonData::erase(Person *dest, size_t idx)
    {
        if (idx >= d_size)
            throw string("Array bounds exceeded");
        *dest = d_data[idx];
        Person *tmp = copyAllBut(idx);
        delete[] d_data;
        d_data = tmp;
        --d_size;
    }
이렇게 변경해도 작동은 하지만 원래 객체를 돌려주는 멤버를 만든다는 원래 과업의 목적에 어긋나 버린다. 그렇지만 두 함수 모두 과도한 과업 때문에 고통을 겪는다. PersonDb의 데이터를 변경하고 또 원래의 객체를 돌려주기 때문이다. 이와 같은 상황에 함수-하나에-책임-하나라는 규칙을 명심해야 한다. 함수는 정의가 잘된 책임을 하나만 져야 한다.

좋은 접근법은 Person const &at(size_t idx) const와 같은 멤버를 사용하여 PersonDb의 객체를 열람하고 void PersonData::erase(size_t idx)와 같은 멤버를 사용하여 객체를 삭제하는 것이다.

10.10.3: 절대(nothrow) 보장

예외 안전성은 함수와 연산이 예외를 던지지 않는다고 보장될 때에만 실현할 수 있다. 이를 절대 보장(nothrow 보장)이라고 부른다. 절대 보장을 제공해야 하는 함수의 예는 swap 함수이다. 다시 한 번 표준 중복정의 할당 연산자를 연구해 보자:
    Class &operator=(Class const &other)
    {
        Class tmp(other);
        swap(tmp);
        return *this;
    }
swap 함수가 예외를 던져도 허용한다면 완전히 교체되지 않은 상태로 현재 객체를 떠날 가능성이 높다. 결과적으로 현재 객체의 상태가 바뀌어 있을 가능성이 매우 높을 것이다. 나포 처리자가 던져진 예외를 잡을 즈음이면 tmp가 이미 파괴되어 없으므로 객체의 원래 상태를 열람하기가 아주 어렵다 (사실상 불가능하다). 결과적으로 강력 보장을 할 수 없다.

그러므로 swap 함수는 절대 보장을 제공해야 한다. 마치 다음과 같은 원형처럼 사용되도록 설계되어 있어야 한다 (23.7절도 참고):

    void Class::swap(Class &other) noexcept;

마찬가지로 operator deleteoperator delete[]는 절대 보장을 제공한다. C++ 표준에 따라 소멸자는 자체로 예외를 던지지 않는다 (만약 예외를 던지면 어떻게 대응해야 하는지 공식적으로 정의되어 있지 않다. 아래 10.12절 참고).

C 언어는 예외 개념이 정의되어 있지 않으므로 표준 C 라이브러리의 함수는 절대 보장을 묵시적으로 제공한다. 이 덕분에 9.6절memcpy를 사용하여 총칭 swap 함수를 작성할 수 있다.

원시 유형의 연산은 절대 보장을 제공한다. 포인터를 재할당할 수 있고 참조를 돌려줄 수 있다. 등등. 혹시나 예외가 던져질까 걱정할 필요가 없다.

10.11: 함수 try 블록

생성자가 멤버를 초기화하는 동안에 예외가 일어날 수도 있다. 그런 상황에 던져진 예외는 어떻게 생성자 밖이 아니라 생성자 자체에서 잡을 수 있을까? 직관적인 해결책으로 객체의 생성을 try 블록 안에 내포시키는 것은 문제를 해결하지 못한다. 그때 쯤이면 예외는 생성자를 떠나 있고 생성하고자 하는 객체는 아직 완성되지 않았기 때문이다.

내포 try 블록을 사용하는 방법은 다음 예제에 보여준다. mainPersonDb의 객체를 정의한다. PersonDb의 생성자가 예외를 던진다고 가정하자. PersonDb의 생성자가 할당해 둔 자원에 catch 처리자가 접근할 방법이 없다. pdb 객체가 영역을 벗어나 있기 때문이다.

    int main(int argc, char **argv)
    {
        try
        {
            PersonDb pdb(argc, argv);   // 예외를 던질 가능성이 있다.
            ...                         // main()의 다른 코드
        }
        catch(...)                      // 그리고/또는 다른 처리자
        {
            ...                         // 여기에서 pdb에 접근할 수 없다.
        }
    }

try 블록 안에 정의된 모든 객체와 변수는 관련된 catch 처리자로부터 접근할 수 없지만 객체 데이터 멤버는 try 블록을 시작하기 전에 사용할 수 있다. 그래서 catch 처리자가 접근할 수 있다. 다음 예제에서 PersonDb 생성자의 catch 처리자는 자신의 d_data 멤버에 접근할 수 있다.

    PersonDb::PersonDb(int argc, char **argv)
    :
        d_data(0),
        d_size(0)
    {
        try
        {
            initialize(argc, argv);
        }
        catch(...)
        {
            // d_data, d_size: 접근 가능
        }
    }

안타깝게도 이것은 별로 도움이 되지 않는다. PersonDb const pdb가 정의되어 있으면 initialize 멤버는 d_datad_size를 재할당할 수 없다. initialize 멤버는 적어도 기본 예외 보장을 해야 한다. 그리고 던져진 예외 때문에 종료하기 전에 획득한 자원을 모두 돌려주어야 한다. 원시 데이터 유형이기 때문에 d_datad_size는 절대 보장을 제공하지만 클래스 유형의 데이터 멤버라면 예외를 던질 가능성이 있고 그 결과로 기본 보장을 범할 가능성이 있다.

PersonDb의 다음 구현에 생성자가 이미 할당된 Person 객체의 메모리 블록을 포인터로 받는다고 가정하자. PersonDb 객체는 할당된 메모리의 소유권을 획득한다. 그러므로 할당된 메모리를 끝까지 파괴할 책임이 있다. 게다가 d_datad_sizePersonDbSupport 합성 객체도 사용한다. 그의 생성자는 Person const *size_t 인자를 기대한다. 구현은 다음과 같이 보일 것이다.

    PersonDb::PersonDb(Person *pData, size_t size)
    :
        d_data(pData),
        d_size(size),
        d_support(d_data, d_size)
    {
        // 더 이상 조치 없음
    }
이렇게 설정하면 PersonDb const &pdb를 정의할 수 있다. 불행하게도 PersonDb는 기본 보장을 제공하지 않는다. PersonDbSupport의 생성자가 예외를 던지더라도 잡히지 않는다. 물론 이미 할당된 메모리를 d_data가 가리키고 있기 때문이다.

이 문제의 해결책을 함수 try 블록이 제공한다. 함수 try 블록은 try 블록과 연관 처리자들로 구성된다. 함수 try 블록은 함수 헤더 다음에 곧바로 시작하고 그의 블록은 함수 몸체를 정의한다. 생성자로서 바탕 클래스와 데이터 멤버 초기화자를 try 키워드와 여는 활괄호 사이에 배치할 수 있다. 다음은 PersonDb의 최종 구현이다. 이제 기본 보장을 제공한다.

    PersonDb::PersonDb(Person *pData, size_t size)
    try
    :
        d_data(pData),
        d_size(size),
        d_support(d_data, d_size)
    {}
    catch (...)
    {
        delete[] d_data;
    }

불필요한 것을 모두 뺀 예제를 하나 살펴 보자. 생성자는 함수 try 블록을 정의한다. Throw 객체가 던진 예외는 제일 처음 자체에서 잡는다. 다음 그 예외를 되던진다. 바깥의 Composer의 생성자도 함수 try 블록을 정의한다. Throw가 되던진 예외는 적절하게 Composer의 예외 처리자가 잡는다. 그의 멤버 초기화 리스트 안에서 예외가 일어났을지라도 적절하게 잡는다.

    #include <iostream>

    class Throw
    {
        public:
            Throw(int value)
            try
            {
                throw value;
            }
            catch(...)
            {
                std::cout << "Throw's exception handled locally by Throw()\n";
                throw;
            }
    };

    class Composer
    {
        Throw d_t;
        public:
            Composer()
            try             // 주의: 초기화 리스트보다 먼저 시도
            :
                d_t(5)
            {}
            catch(...)
            {
                std::cout << "Composer() caught exception as well\n";
            }
    };

    int main()
    {
        Composer c;
    }

이 예제를 실행하면 놀라운 일을 겪는다. 프로그램은 실행되고 중단 예외와 함께 깨진다. 다음은 프로그램의 출력이다. 마지막 두 줄은 시스템의 마지막 총괄 나포 처리자가 추가한 것이다. 마지막 처리자는 잡지 못한 나머지 예외를 모두 잡는다.

    Throw's exception handled locally by Throw()
    Composer() caught exception as well
    terminate called after throwing an instance of 'int'
    Abort
그 이유는 C++ 표준에 문서화되어 있다. 생성자나 소멸자 함수 try 블록에 속한 나포-처리자의 끝에서 원래의 예외가 자동으로 되던져진다.

처리자 자체에서 또다른 예외를 던지면 그 예외는 되던져지지 않는다. 던져진 예외를 또다른 예외로 교체할 방법을 생성자나 소멸자에게 제공한다. 예외는 생성자나 소멸자 함수 try 블록의 나포 처리자의 끝에 다다를 경우에만 되던져진다. 내포된 나포 처리자가 잡은 예외는 자동으로 되던져지지 않는다.

생성자와 소멸자만 자신의 함수 try 블록 나포 처리자 안에서 잡은 예외를 되던지기 때문에 위 예제의 실행 시간 에러는 간단하게 고칠 수 있다. main 함수에 따로 함수 try 블록을 배치하면 된다.

    int main()
    try
    {
        Composer c;
    }
    catch (...)
    {}
이제 프로그램은 계획대로 실행되고 다음과 같이 출력한다.
    Throw's exception handled locally by Throw()
    Composer() caught exception as well

마지막 당부 말씀을 드린다. 함수 try 블록을 정의한 함수가 또 예외 나포 리스트를 선언하면 되던져진 예외의 유형만 그 리스트에 언급된 유형에 부합한다.

10.12: 생성자와 소멸자의 예외

소멸자는 완전히 생성된 객체에만 작동한다. 당연히 옳은 말처럼 들리지만 미묘한 점이 있다. 어떤 이유로 객체가 생성되지 못하면 영역을 벗어나더라도 그 객체의 소멸자는 호출되지 않는다. 생성자가 예외를 잡지 못하면 이런 일이 일어날 수 있다. 이미 메모리를 할당했는데 예외가 던져지면 그 메모리는 반납되지 않는다. 객체가 생성되지 못하면 소멸자가 호출되지 않기 때문이다.

다음 예제는 원형적 형태로 이 상황을 보여준다. Incomplete 클래스의 생성자는 먼저 메시지를 보여준 다음에 예외를 던진다. 소멸자도 메시지를 보여준다.

    class Incomplete
    {
        public:
            Incomplete()
            {
                cerr << "Allocated some memory\n";
                throw 0;
            }
            ~Incomplete()
            {
                cerr << "Destroying the allocated memory\n";
            }
    };

다음으로 main()Incomplete 객체를 try 블록 안에 생성한다. 예외는 일어나면 무엇이든 잇달아 나포된다.

    int main()
    {
        try
        {
            cerr << "Creating `Incomplete' object\n";
            Incomplete();
            cerr << "Object constructed\n";
        }
        catch(...)
        {
            cerr << "Caught exception\n";
        }
    }

이 프로그램을 실행하면 다음과 같이 출력한다.

    Creating `Incomplete' object
    Allocated some memory
    Caught exception
그리하여 Incomplete의 생성자가 실제로 메모리를 할당했다면 프로그램은 메모리 누수를 겪게 된다. 이를 방지하기 위해 다음의 참조 횟수 세기 방법을 사용할 수 있다. 생성자 함수의 try 블록에 있는 catch 절은 평범한 함수의 try 블록에 있는 catch 절과 약간 다르게 행위한다. 생성자 함수의 try 블록에 도달하는 예외는 또다른 예외로 변형이 가능하지만 (catch 절에서 던짐) catch 절에서 예외를 명시적으로 던지지 않으면 그 예외는 언제나 다시 던져진다. 결과적으로 바탕 클래스의 생성자나 멤버 초기화자에게서 던져진 예외를 생성자 안으로 제한할 방법이 없다. 그런 예외는 언제나 좀더 바깥 수준으로 전파되고 그리하여 객체의 생성은 언제나 미완성이라고 간주된다.

결과적으로 미완성 객체가 예외를 던지면 생성자의 catch 절이 메모리(자원) 누수를 책임을 지고 막아야 한다. 이를 실현하려면 몇 가지 방법이 있다.

반면에 C++는 생성자 위임을 지원하기 때문에 C++ 실행 시간 시스템에 맞게 객체를 완벽하게 생성할 수는 있지만 여전히 그의 생성자는 예외를 던질 가능성이 있다. 위임된 생성자는 성공적으로 임무를 마쳤지만 (이후로 객체는 `완벽하게 생성되었다'고 간주된다) 해당 생성자 자체가 예외를 던지면 이런 일이 일어난다. 다음 예제에 보여준다.

    class Delegate
    {
        public:
            Delegate()
            :
                Delegate(0)
            {
                throw 12;       // 예외는 던지지만 완전하게 생성됨
            }
            Delegate(int x)         // 생성 완료
            {}
    };
    int main()
    try
    {
        Delegate del;           // 예외를 던짐

    } // del의 소멸자가 여기에서 호출됨
    catch (...)
    {}
이 예제에서 Delegate의 설계자는 예외를 던지는 기본 생성자가 Delegate의 소멸자가 수행한 조치를 무효화하지 못하도록 책임을 지고 확인해야 한다. 예를 들어 위임된 생성자가 메모리를 배당했지만 소멸자에게 파괴될 운명이라면 기본 생성자는 메모리를 그대로 두거나 아니면 메모리를 삭제하고 상응하는 포인터를 0으로 설정할 수 있다. 어느 경우든 객체가 유효한 상태로 남아 있는지 확인하는 것은 비록 예외를 던질지라도 Delegate의 책임이다.

C++ 표준에 의하면 예외는 소멸자를 벗어나면 안된다. 그러므로 소멸자에 함수 try 블록을 제공하는 것은 표준에 어긋난다. 함수 try 블록의 catch 절에 잡힌 예외는 이미 소멸자를 떠났기 때문이다. 만약 표준을 위배하여 소멸자에 함수 try 블록을 제공하고 예외가 그 try 블록에 잡힌다면 그 예외는 다시 던져진다. 생성자 함수의 try 블록의 catch 절에서 일어나는 일과 비슷하다.

예외가 소멸자를 벗어날 경우 그 결과는 정의되어 있지 않다. 예상치 못한 행위를 야기할 수도 있다. 다음 예제를 연구해 보자:

서랍 하나가 달린 찬장을 짠다고 가정해 보자. 찬장이 완성되고 그 찬장을 산 고객은 기대한 대로 사용할 수 있음을 알게 된다. 그 고객은 찬장에 아주 만족하여 이 번에는 서랍이 두 개 달린 찬장을 주문한다. 두 번째 찬장이 완성되어 배달된다. 고객은 몹시 놀란다. 처음 열자마자 바로 완전히 부서졌기 때문이다.

이상한 이야기 같은가? 그러면 다음 프로그램을 연구해 보자:

    int main()
    {
        try
        {
            cerr << "Creating Cupboard1\n";
            Cupboard1();
            cerr << "Beyond Cupboard1 object\n";
        }
        catch (...)
        {
            cerr << "Cupboard1 behaves as expected\n";
        }
        try
        {
            cerr << "Creating Cupboard2\n";
            Cupboard2();
            cerr << "Beyond Cupboard2 object\n";
        }
        catch (...)
        {
            cerr << "Cupboard2 behaves as expected\n";
        }
    }

이 프로그램을 실행하면 다음과 같이 출력한다.

    Creating Cupboard1
    Drawer 1 used
    Cupboard1 behaves as expected
    Creating Cupboard2
    Drawer 2 used
    Drawer 1 used
    terminate called after throwing an instance of 'int'
    Abort
마지막의 Abort는 프로그램이 취소되었다는 뜻이다. 원래는 Cupboard2 behaves as expected 메시지를 보여주었어야 한다.

관련된 세 클래스를 살펴 보자. Drawer 클래스는 별로 큰 특징이 없다. 단, 그의 소멸자가 예외를 던진다.

    class Drawer
    {
        size_t d_nr;
        public:
            Drawer(size_t nr)
            :
                d_nr(nr)
            {}
            ~Drawer()
            {
                cerr << "Drawer " << d_nr << " used\n";
                throw 0;
            }
    };

Cupboard1 클래스도 역시 특징이 없다. 그저 한 개짜리 합성 Drawer 객체를 가졌을 뿐이다.

    class Cupboard1
    {
        Drawer left;
        public:
            Cupboard1()
            :
                left(1)
            {}
    };

Cupboard2 클래스도 똑같이 생성된다. 그러나 합성 Drawer 객체를 두 개 가졌다.

    class Cupboard2
    {
        Drawer left;
        Drawer right;
        public:
            Cupboard2()
            :
                left(1),
                right(2)
            {}
    };

Cupboard1의 소멸자가 호출될 때 Drawer의 소멸자가 결국 호출되어 자신의 합성 객체를 파괴한다. 이 소멸자는 예외를 던진다. 예외는 멀리 프로그램의 첫번째 try 블록에서 나포된다. 이 행위는 완전히 예상대로이다.

여기에서 미묘한 점은 생성하자마자 Cupboard1의 소멸자가 (그러므로 Drawer의 소멸자가) 곧바로 이어서 활성화된다는 것이다. 생성하자마자 그의 소멸자가 즉시 호출된다. Cupboard1()이 익명 객체를 정의하기 때문이다. 결과적으로 Beyond Cupboard1 object 텍스트는 절대로 std::cerr에 삽입되지 않는다.

예외를 던지는 Drawer의 소멸자 때문에 Cupboard2의 소멸자를 호출할 때 문제가 일어난다. 두 합성 객체 중에서 두 번째 Drawer의 소멸자가 먼저 호출된다. 이 소멸자는 예외를 던진다. 이 예외는 멀리 프로그램의 두 번째 try 블록에서 잡아야 한다. 그렇지만 그때 쯤이면 실행 흐름은 Cupboard2의 소멸자 문맥을 떠나 있는데도 그 객체는 아직 완전히 파괴되어 있지 않다. (왼쪽의) 다른 Drawer의 소멸자가 여전히 호출되고 있기 때문이다.

보통 그것은 큰 문제가 아니다. 예외가 Cupboard2의 소멸자로부터 던져지더라도 나머지 조치는 그냥 무시될 것이기 때문이다. 그럼에도 불구하고 (두 서랍 객체 모두 적절히 생성된 객체이기 때문에) left의 소멸자는 여전히 호출될 것이다.

이런 일이 여기에서도 일어난다. left의 소멸자는 예외도 던질 필요가 있다. 그러나 이미 두 번째 try 블록의 문맥을 떠났으므로 현재의 실행 흐름은 이제 완전히 뒤죽박죽이다. 프로그램은 포기하는 방법 말고는 다른 선택이 없다. 그래서 terminate()를 호출하여 끝낸다. 이 번에는 순서대로 abort()를 호출한다. 이로서 서랍 두 개 짜리 찬장은 붕괴되었다. 서랍 하나 짜리 찬장은 완벽하게 작동했지만 말이다.

여러 합성 객체가 소멸자를 떠나 예외를 던지기 때문에 프로그램은 끝난다. 이 상황에서 합성 객체 중 하나는 프로그램의 실행 흐름이 이미 그의 적절한 문맥을 떠날 즈음이면 예외를 던질 것이다. 그 때문에 프로그램은 끝나 버린다.

그러므로 C++ 표준은 다음과 같이 요구한다. 예외는 절대로 소멸자를 떠나면 안된다. 다음은 예외를 던지는 소멸자의 뼈대이다. 소멸자의 조치를 제외하고 어떤 함수 try 블록도 소멸자 몸체 아래에 있는 try 블록 안에 캡슐화되어 들어 있지 않다.

    Class::~Class()
    {
        try
        {
            maybe_throw_exceptions();
        }
        catch (...) // 절대로 전부 잡아야 한다.
        {}
    }