본문 바로가기
프로그래밍/C & C++

[C++] Error Handling (1)

by 별준 2022. 3. 6.

References

Contents

  • Errors and Exceptions
  • Exception Mechanics
  • Exceptions and Polymorphism
  • Rethrowing Exceptions

프로그래밍을 하다보면 파일을 열 수 없거나, 네트워크 연결이 끊기거나, 사용자가 잘못된 값을 입력하는 등의 에러가 발생하기 마련입니다. C++은 이렇게 예상치 못한 예외적인 상황에 대처하도록 Exception(익셉션, 예외)이라는 기능을 제공합니다. 이번 포스팅에서는 에러 처리를 반영한 개발 방법과 관련 문법, 기능에 대해서 알아보겠습니다.

 

1. Errors and Exceptions

완전히 독립적으로 실행되는 프로그램은 없으며, OS나 네트워크, 파일시스템, 사용자 입력을 비롯한 여러 외부 환경에 어떻게든 의존하기 마련입니다. 이런 대상들과 프로그램이 상호작용하다 보면 여러 가지 문제가 발생하게 되며, 이처럼 프로그램에서 발생할 가능성이 있는 잠재적인 문제들을 예외 상황(exceptional situation)이라고 부릅니다. 프로그램을 아무리 완벽하게 작성하려고 해도 에러나 예외 상황이 발생하는 것을 피할 수는 없습니다. 따라서 프로그램을 작성할 때는 반드시 에러 처리 기능도 함께 제공해야 합니다. C와 같은 언어는 에러 처리 기능을 직접 제공하지 않으며, 함수의 리턴값을 확인하는 듯 여러 가지 프로그래밍 기법을 활용해서 예외 상황에 대처하는 기능을 프로그래머가 직접 구현해야 합니다. 반면 자바처럼 언어에서 제공하는 익셉션 메커니즘을 반드시 활용해서 프로그래밍하도록 강제하는 언어도 있습니다. C++은 C와 자바의 중간쯤에 위치하고 있습니다. 언어에서 익셉션 메커니즘을 제공하지만 반드시 사요할 필요는 없습니다.

 

1.1 Exceptions ?

익셉션(exception)이란 코드에서 발생한 예외 상황이나 에러를 정상적인 실행 흐름에 알려주는 메커니즘입니다. 익셉션 메커니즘을 적용하면 에러가 발생한 코드는 익셉션을 던지고(throw), 이를 처리하는 코드는 발생한 익셉션을 받아서 처리(catch)하는 식으로 동작합니다. 익셉션을 처리하는 과정은 기존 프로그램과 달리 순차적으로 실행되지 않습니다. 어떤 코드가 익셉션을 던지면 프로그램의 정상적인 흐름을 잠시 멈추고 익셉션 핸들러(exception handler)로 제어권을 넘깁니다. 이때 핸들러의 위치는 다양합니다. 함수 바로 뒤에 나올 수도 있고, 연속된 함수 호출(스택 프레임)을 거슬러 올라가야 나올 수도 있습니다. 

예를 들어, 위 그림처럼 세 함수가 연달아 호출되었을 때의 스택 상태를 살펴보겠습니다. 익셉션 핸들러가 있는 A()를 호출한 다음 B(), C() 순으로 호출했는데, C()에서 익셉션이 발생했다고 가정한다면 아래 그림처럼 핸들러가 익셉션을 받는 상황이 됩니다.

이 상태를 보면 C()와 B()에 대한 스택 프레임은 삭제되었고 A()에 대한 스택 프레임만 남아있습니다.

 

1.2 Why Using Exceptions ?

앞서 언급했듯이 프로그램을 실행하다 보면 에러가 발생하기 마련입니다. 하지만 기존에 작성된 C 또는 C++ 프로그램을 보면 에러를 처리하는 방식이 제각각이고 체계가 없는 경우가 많습니다. 함수가 정수 코드를 리턴하거나, errno 매크로를 사용해서 에러를 표시하는 것처럼 C 프로그래밍에서 표준처럼 굳어진 방식이 있습니다. 이 방식을 C++에서도 그대로 적용한 사례도 많습니다. 또한 스레드를 다룰 때는 스레드의 로컬 정수 변수인 errno를 하나씩 만들어두고, 각 스레드가 함수를 호출한 측에 에러는 알려주는 데 이 변수를 활용하기도 합니다.

 

하지만 이렇게 정수 타입 리턴 코드나 errno를 사용하는 방식으로 구현하면 에러 처리 과정의 일관성을 유지하기가 힘듭니다. 예를 들어 어떤 함수는 정상일 때는 0을, 에러가 발생하면 -1을 리턴합니다. -1을 리턴할 때는 errno에 에러 코드도 설정합니다. 또 어떤 함수는 정상일 때는 0을, 에러가 발생했을 때는 0이 아닌 값을 리턴하고, 그 값으로 에러 코드를 표현해서 errno를 따로 사용하지 않습니다. 심지어 어떤 함수는 C나 C++에서 0을 false로 처리한다는 이유로 에러가 발생할 때 0을 리턴하기도 합니다.

 

이처럼 일관성 없이 나름대로 정한 관례대로 구현한 함수들이 뒤섞이면 문제가 발생할 수 있습니다. 호출한 함수가 예상과 다른 방식으로 코드를 리턴하기 때문입니다. 또 다른 문제는 C++ 함수는 리턴 타입을 하나만 지정할 수 있다는 점입니다. 그래서 에러와 결과를 모두 리턴하려면 다른 수단을 마련해야 하는데, 한 가지 방법은 값을 두 개 이상 저장할 수 있는 std::pair나 std::tuple에 결과와 에러를 하나로 묶어서 리턴하는 것입니다. 다른 방법은 여러 값을 담는 struct나 클래스를 직접 정의해서 함수의 결과와 에러 상태를 그 struct나 클래스의 인스턴스로 만들어서 리턴하는 것입니다. 에러나 리턴값을 레퍼런스 매개변수로 전달하거나 리턴 값 중 어느 하나(ex, nullptr)로 표현하는 방법도 있습니다. 어떤 방식을 사용하든지 리턴값을 보고 에러 발생 여부를 확인하는 작업은 함수를 호출한 측의 몫입니다. 다른 곳에서 에러를 처리한다면 그곳으로 반드시 전달해야 하는데, 이렇게 하면 에러에 대한 핵심 세부사항을 놓치기 쉽습니다.

 

C 프로그래머라면 setjmp() / longjmp()에 익숙할 수 있는데, 이 메커니즘은 C++에 맞지 않습니다. 스택의 스코프에 있는 소멸자를 거치지 않기 때문에 스택에 저장된 내용을 정상적으로 제거할 수 없습니다. C++뿐만 아니라 C 프로그래밍에서도 바람직하지 않은 방식입니다.

 

익셉션 메커니즘을 활요하면 에러를 쉽고 일관성 있고 안전하게 처리할 수 있습니다. 기존에 C나 C++에서 활용하던 비공식 에러 처리 기법에 비해 익셉션 메커니즘이 뛰어난 이유는 다음과 같습니다.

  • 에러를 리턴값으로 표현하면 호출한 측에서 깜박하고 리턴값을 검사하지 않거나 상위 함수로 전달하지 못할 수 있습니다. C++17의 [[nodiscard]] 어트리뷰트를 활용하면 리턴 코드를 무시하지 못하게 설정할 수 있긴 하지만 완벽한 해결책이라고는 볼 수 없습니다. 반면 익셉션은 깜박 잊고 처리하지 않거나 무시할 수 없습니다. 발생한 익셉션을 처리하지 않으면 프로그램이 즉시 멈추기 때문입니다.
  • 에러를 정수 타입 리턴 코드로 표현하면 구체적인 정보를 담기 힘듭니다. 반면 익셉션은 에러를 처리하는 데 필요한 정보를 마음껏 담을 수 있습니다. 또한 익셉션은 에러뿐만 아니라 다른 부가 정보도 담을 수 있습니다.
  • 익셉션 메커니즘은 콜 스택의 중간 단계를 건너뛸 수 있습니다. 다시 말해 여러 함수가 연속적으로 호출되었을 때 중간에 호출된 함수에서 에러를 처리하지 않고 콜 스택의 최상위 함수에서 에러를 처리하게 만들 수 있습니다. 반면 리턴 코드를 활용하면 함수 호출의 각 단계마다 반드시 에러 코드를 다음 단계로 전달하도록 작성해야 합니다.

요즘은 드물지만 예전 컴파일러에서는 에러를 처리하는 모든 함수에 오버헤드가 발생하는 경우가 많았습니다. 최신 컴파일러는 익셉션이 발생하지 않으면 오버헤드가 거의 없고, 익셉션이 실제로 발생했을 때만 약간의 오버헤드가 발생하도록 타협하고 있습니다. 익셉션은 말 그대로 예외이기 때문에 이렇게 상황에 따라 오버헤드를 최소화하도록 처리하는 것이 합리적입니다.

 

자바와 달리 C++은 익셉션을 처리하지 않아도 됩니다. 예를 들어 자바는 메소드에 미리 지정해두지 않은 익셉션을 던질 수 없습니다. C++은 이와 반대로 noexception 키워드로 익셉션이 절대 발생하지 않는다고 명시하지 않는 한 원하는 익셉션을 마음껏 던질 수 있습니다.

 


2. Exception Mechanics

특히 파일 입출력 과정에서 예외 상황이 발생하기 쉽습니다. 아래 코드는 파일을 열고, 그 파일에 담긴 정수 목록을 읽어서 std::vector에서 담아 리턴하는 함수를 구현하고 있습니다. 이 함수에는 에러 처리 코드가 없습니다.

#include <vector>
#include <fstream>
#include <string_view>

std::vector<int> readIntegerFile(std::string_view filename)
{
    std::ifstream inputStream{ filename.data() };
    // Read the integers one-by-one and add them to a vector
    std::vector<int> integers;
    int temp;
    while (inputStream >> temp) {
        integers.push_back(temp);
    }
    return integers;
}

여기서 line 11의 while문 조건을 살펴보면 파일 끝에 도달하거나 에러가 발생하기 전까지 ifstream에서 읽은 값을 저장합니다.

while (inputStream >> temp) {

>> 연산을 수행할 때 에러가 발생하면 ifstream 객체에 에러 플래스(fail bit)가 설정됩니다. 그러면 bool() 변환 연산자가 false를 리턴하면서 while 루프가 종료됩니다.

 

앞서 정의한 readIntegerFile() 함수를 사용하는 방법은 다음과 같습니다.

int main()
{
    const std::string filename{ "IntegerFile.txt" };
    std::vector<int> myInts{ readIntegerFile(filename) };
    for (const auto& element : myInts) {
        std::cout << element << " ";
    }
    std::cout << std::endl;
}

아래에서 C++의 익셉션 기능으로 이 함수에 에러 처리 코드를 추가하는 방법을 단계적으로 살펴보겠습니다. 그전에 우선 익셉션을 던지고 받는 방법부터 확실하게 짚고 넘어가도록 하겠습니다.

 

2.1 Throwing and Catching Exceptions

프로그램에 익셉션을 구현하는 코드는 두 부분으로 나눌 수 있습니다. 하나는 발생한 익셉셔을 처리하는 try/catch문이고, 다른 하나는 익셉션을 던지는 throw문 입니다. 둘 다 반드시 지정된 형식에 맞게 작성해야 합니다. 하지만 throw문이 실행되는 지점은 대부분 C++ 런타임과 같이 어떤 라이브러리의 깊숙한 곳에 있어서 프로그래머가 직접 볼 수 없을 때가 많습니다. 그렇다 하더라도 try/catch 구문으로 반드시 처리해주어야 합니다.

 

try/catch문은 다음과 같이 구성됩니다.

try {
    // ... code which may result in an exception being throw
} catch (exception-type1 exception-name) {
    // ... code which responds th the exception of type 1
} catch (exception-type2 exception-name) {
    // ... code which responds th the exception of type 2
}
// ... remaining code

예외 상황이 발생할 수 있는 코드에 throw문으로 익셉션을 직접 던져도 됩니다. 또한 throw문으로 익셉션을 직접 던지거나 익셉션을 던지는 함수를 호출하는 문장이 담긴 함수를 호출할 수도 있습니다. 후자의 경우에는 여러 단계의 호출 과정을 거칠 수도 있습니다.

 

익셉션이 발생하지 않으면 catch 블록은 실행되지 않고, try 문의 마지막 문장을 실행하고 나서 try/catch문을 빠져나와 바로 다음 문장을 실행합니다.

반면 익셉션이 발생하면 throw 또는 throw문이 담긴 함수를 호출하는 문장의 바로 뒤에 있는 코드는 실행되지 않고, 발생한 익셉션의 타입에 맞는 catch 블록으로 실행 흐름이 바뀝니다.

 

catch 블록에서 더 이상 실행 흐름이 바뀌지 않는다면, 즉, 어떤 값을 리턴하거나 다른 익셉션을 던지거나, 발생한 익셉션을 그대로 던지는 등의 작업을 수행하지 않으면 방금 실행한 catch 블록의 마지막 문장을 끝낸 후 try/catch문을 빠져나와 그다음 코드를 실행합니다.

 

익셉션 처리 코드를 작성하는 방법을 구체적으로 살펴보기 위해 다음과 같이 0으로 나누는 상황을 체크하는 함수를 만들어보겠습니다. 이 코드는 <stdexcept> 헤더에 정의된 std::invalid_argument라는 익셉션을 던집니다.

#include <iostream>
#include <stdexcept>

double SafeDivide(double num, double den)
{
    if (den == 0)
        throw std::invalid_argument{ "Divide by zero" };
    return num / den;
}

int main()
{
    try {
        std::cout << SafeDivide(5, 2) << std::endl;
        std::cout << SafeDivide(10, 0) << std::endl;
        std::cout << SafeDivide(3, 3) << std::endl;
    }
    catch (const std::invalid_argument& e) {
        std::cout << "Caught exception: " << e.what() << std::endl;
    }
}

위 코드를 실행한 결과는 다음과 같습니다.

여기서 throw는 C++에 정의된 키워드로서, 익셉션을 던지려면 반드시 이 키워드를 사용해야 합니다. throw문에서 나온 invalid_argument()는 던질 invalid_argument 타입의 익셉션 객체를 생성합니다. invalid_argument는 C++ 표준 라이브러리에서 제공하는 표준 익셉션 중의 하나입니다. 표준 라이브러리에 정의된 익셉션을 일정한 계층을 형성하고 있는데, 이에 대해서는 뒤에서 다시 설명하도록 하겠습니다. 이 계층 구조에 속한 클래스마다 what() 메소드가 있는데, 이 메소드는 익셉션을 표현하는 const char* 스트링을 리턴합니다. 이 값은 익셉션 생선자의 인수로 전달하는 방식으로 설정합니다.

 

처음에 살펴본 readIntegerFile() 함수를 다시 살펴보도록 하겠습니다. 이 코드에서 발생할 수 있는 가장 심각한 문제는 파일을 열 때 에러가 발생할 수 있다는 것입니다. 따라서 이 과정에서 익셉션을 던질 수 있도록 수정합니다. 이때 <stdexcept> 헤더에 정의된 std::exception 타입으로 익셉션을 생성합니다.

std::vector<int> readIntegerFile(std::string_view filename)
{
    std::ifstream inputStream{ filename.data() };
    if (inputStream.fail()) {
        // We failed to open the file: throw an exception
        throw std::exception{};
    }
    // Read the integers one-by-one and add them to a vector
    std::vector<int> integers;
    int temp;
    while (inputStream >> temp) {
        integers.push_back(temp);
    }
    return integers;
}

 

이 함수에서 파일 열기에 실패하면 throw exception() 문장이 실행되면서 함수의 나머지 코드를 건너뛰고 가장 가까운 핸들러 코드로 실행 흐름이 바뀝니다.

 

익셉션을 던지는 코드와 이를 처리하는 코드는 항상 나란히 작성하는 것이 좋습니다. 익셉션 처리 과정을 다르게 표현하면 어떤 코드 블록을 실행하다가 문제가 발생하면 다른 코드 블록으로 대처하는 것입니다. 아래의 main 함수는 try 블록에서 던진 exception 타입의 익셉션에 대해 catch문에서 에러 메세지를 출력하는 방식으로 처리합니다. 여기 나온 try 블록에서 익셉션이 하나도 발생하지 않으면 catch 블록은 실행되지 않습니다.

int main()
{
    const std::string filename{ "IntegerFile.txt" };
    std::vector<int> myInts;
    try {
        myInts = readIntegerFile(filename);
    }
    catch (const std::exception& e) {
        std::cerr << "Unable to open file " << filename << std::endl;
        return 1;
    }
    for (const auto& element : myInts) {
        std::cout << element << " ";
    }
    std::cout << std::endl;
}

 

2.2 Exception Types

던질 수 있는 익셉션의 타입에는 제한이 없습니다. 위에서 본 예제는 std::exception 타입으로 던졌지만, 반드시 이 타입으로만 던지라는 법은 없습니다. 다음과 같이 간단한 int 타입 객체를 던져도 됩니다.

std::vector<int> readIntegerFile(std::string_view filename)
{
    std::ifstream inputStream{ filename.data() };
    if (inputStream.fail()) {
        // We failed to open the file: throw an exception
        throw 5;
    }
    // ... 나머지 코드 생략
}

이렇게 변경하면, catch문도 다음과 같이 변경해주어야 합니다.

try {
    myInts = readIntegerFile(filename);
}
catch (int e) {
    std::cerr << "Unable to open file " << filename << " (" << e << ")" << std::endl;
    return 1;
}

 

또한 다음과 같이 C 스타일 스트링인 const char* 타입으로 던져도 됩니다. 스트링에 예외 상황의 정보를 담을 때 유용한 방법입니다.

std::vector<int> readIntegerFile(std::string_view filename)
{
    std::ifstream inputStream{ filename.data() };
    if (inputStream.fail()) {
        // We failed to open the file: throw an exception
        throw "Unable to open file";
    }
    // ... 나머지 코드 생략
}

const char* 타입의 익셉션을 받는 부분은 다음과 같이 그 값을 출력할 수 있습니다.

try {
    myInts = readIntegerFile(filename);
}
catch (const char* e) {
    std::cerr << e <<  std::endl;
    return 1;
}

 

하지만 방금 본 두 예제처럼 기본 타입을 사용하기보다는 타입을 새로 정의하는 것이 바람직한데, 그 이유는 다음과 같습니다.

  • 객체의 클래스 이름에 예외 상황에 대한 정보를 드러낼 수 있음
  • 예외 상황의 종류뿐만 아니라 다른 정보도 담을 수 있음

C++ 표준 라이브러리에 미리 정의되어 있는 익셉션 클래스를 사용할 수도 있고, 익셉션 클래스를 직접 정의할 수도 있습니다. 구체적인 방법은 아래에서 살펴보겠습니다.

 

2.3 Catch Exception Object as Reference-to-const

앞서 나온 readIntegerFile()에서 exception 객체를 던질 때 catch문을 다음과 같이 작성했습니다.

catch (const std::exception& e) {

하지만 익셉션 객체를 const 레퍼런스로 받지 않아도 됩니다. 다음과 같이 그냥 값으로 받아도 됩니다.

catch (std::exception e) {

또는 non-const 레퍼런스로 받아도 됩니다.

catch (std::exception& e) {

또한 const char* 타입으로 던지는 예제에서 본 것처럼 포인터 타입을 던져도 됩니다.

익셉션 객체는 항상 const 레퍼런스로 받는 것이 좋은데, 익셉션 객체를 값으로 받으면 객체 슬라이싱(object slicing)이 발생합니다.

 

2.4 Throwing and Catching Multiple Exceptions

readIntegerFile()에서는 파일 열기 실패 말고도 다른 문제가 얼마든지 발생할 수 있습니다. 파일에서 데이터를 읽는 도중 포맷에 문제가 있어서 에러가 발생할 수도 있습니다. 이처럼 파일 열기에 실패하거나 데이터 읽기에 오류가 발생할 때 익셉션을 던지도록 readIntegerFile()을 다음과 같이 수정할 수 있습니다.

std::vector<int> readIntegerFile(std::string_view filename)
{
    std::ifstream inputStream{ filename.data() };
    if (inputStream.fail()) {
        // We failed to open the file: throw an exception
        throw std::runtime_error{ "Unable to open the file." };
    }
    // Read the integers one-by-one and add them to a vector
    std::vector<int> integers;
    int temp;
    while (inputStream >> temp) {
        integers.push_back(temp);
    }

    if (!inputStream.eof()) {
        // We did not reach the end-of-file.
        // This means that some error occurred while reading the file.
        // Throw an exception.
        throw std::runtime_error{ "Error reading the file." };
    }
    return integers;
}

이번에는 exception을 상속한 runtime_error로 구현했습니다. 이 타입은 생성자를 호출할 때 예외에 대한 설명을 지정할 수 있습니다.

 

앞에서 main() 함수를 작성할 때 catch 구문이 runtime_error의 베이스 클래스인 exception 타입을 받도록 지정해두었기 때문에 여기서는 변경할 필요가 없습니다. 이렇게 하면 catch 문은 두 가지 상황을 모두 처리할 수 있습니다.

try {
    myInts = readIntegerFile(filename);
}
catch (const std::exception& e) {
    std::cerr << e.what() << std::endl;
    return 1;
}

 

이렇게 하지 않고 readIntegerFile()에서 익셉션을 두 가지 타입으로 따로 나눠서 던져도 됩니다. 다음 코드를 보면 파일을 열 수 없으면 invalid_argument 익셉션을 던지고, 정수를 읽을 수 없으면 runtime_error 익셉션을 던집니다.

std::vector<int> readIntegerFile(std::string_view filename)
{
    std::ifstream inputStream{ filename.data() };
    if (inputStream.fail()) {
        // We failed to open the file: throw an exception
        throw std::invalid_argument{ "Unable to open the file." };
    }
    // Read the integers one-by-one and add them to a vector
    std::vector<int> integers;
    int temp;
    while (inputStream >> temp) {
        integers.push_back(temp);
    }

    if (!inputStream.eof()) {
        // We did not reach the end-of-file.
        // This means that some error occurred while reading the file.
        // Throw an exception.
        throw std::runtime_error{ "Error reading the file." };
    }
    return integers;
}

invalid_argument와 runtime_error에는 public 디폴트 생성자가 없고, string 인수를 받는 생성자만 있습니다.

 

이제 main()에 invalid_argument를 받는 catch문과 runtime_error를 받는 catch문을 따로 작성합니다.

try {
    myInts = readIntegerFile(filename);
}
catch (const std::invalid_argument& e) {
    std::cerr << e.what() << std::endl;
    return 1;
}
catch (const std::runtime_error& e) {
    std::cerr << e.what() << std::endl;
    return 2;
}

try 블록에서 익셉션이 발생하면 컴파일러는 그 익셉션 타입과 일치하는 catch문(핸들러)을 선택합니다. 그러므로 readIntegerFile()에서 파일을 열 수 없으면 invalid_argument 객체를 던지고, main()의 첫 번째 catch문에서 이를 처리합니다. 반면 파일을 읽는데 문제가 발생하면 runtime_error 객체를 던지고, main()의 두 번째 catch문에서 이를 처리합니다.

 

2.4.1 Matching and const

처리하려는 익셉션 타입에 const가 지정되었는지 여부는 매칭 과정에 영향을 미치지 않습니다. 다시 말해 다음 문장은 runtime_error 타입에 속하는 모든 익셉션을 매칭합니다.

catch (const std::runtime_error& e) {

다음 문장도 마찬가지로 runtime_error 타입에 속하는 모든 익셉션을 매칭합니다.

catch (std::runtime_error& e) {

 

2.4.2 Matching Any Exception

catch문에서 모든 종류의 익셉션에 매칭하려면 다음과 같이 특수한 문법으로 작성합니다.

try {
    myInts = readIntegerFile(filename);
}
catch (...) {
    cerr << "Error reading or opening file " << filename << endl;
    return 1;
}

점 3개를 연달아 쓴 것은 오타가 아니고, 모든 익셉션 타입에 매칭하라는 와일드카드입니다. 발생 가능한 익셉션을 확실히 알 수 없어서 모든 익셉션을 받게 만들 때 유용합니다. 하지만 발생 가능한 익셉션을 확실히 알 수 있따면 이렇게 구현하지 않는 것이 좋습니다. 필요없는 익셉션까지 처리하기 때문인데, 항상 익셉션 타입을 구체적으로 지정해서 꼭 필요한 익셉션만 받도록 작성하는 것이 바람직합니다.

 

모든 종류의 익셉션을 매칭하는 catch(...) 구문은 디폴트 catch 핸들러를 구현할 때도 유용합니다. 익셉션이 발생하면 catch 핸들러가 코드에 나열된 순서대로 검색하면서 조건에 맞는 것을 실행합니다. 다음 예는 invalid_argument와 runtime_error만 catch문을 별도로 작성하고 나머지 익셉션은 디폴트 catch 핸들러로 처리하는 방법을 보여줍니다.

try {
    myInts = readIntegerFile(filename);
}
catch (const std::invalid_argument& e) {
    std::cerr << e.what() << std::endl;
    return 1;
}
catch (const std::runtime_error& e) {
    std::cerr << e.what() << std::endl;
    return 2;
}
catch (...) {
    // 나머지 익셉션 처리
}

 

2.5 Uncaught Exceptions

프로그램에서 발생한 익셉션을 처리하는 곳이 하나도 없으면 프로그램이 종료됩니다. 그래서 미처 처리하지 못한 익셉션을 모두 잡도록 main() 함수 전체를 try/catch 구문으로 감싸는 패턴을 많이 사용합니다. 예를 들면 다음과 같습니다.

try {
    main(argc, argv);
}
catch (...) {
    // Issue error message and terminate program.
}
// Normal termination code

그런데 이렇게 하면 아쉬운 점이 있습니다. 애초에 익셉션을 사용하는 이유는 바람직하지 않은 예외 상황이 발생했을 때 대처할 기회를 얻는 데 있기 때문입니다.

 

catch 구문으로 처리하지 못한 익셉션이 남아 있다면 프로그램을 다르게 실행하도록 구현하는 방법도 있습니다. 예를 들어 프로그램이 잡지 못한 익셉션을 만나면 terminate() 함수를 호출하게 만들 수 있습니다. 이 함수는 C++에서 기본으로 제공하며, 내부적으로 <cstdlib> 헤더에 정의된 abort() 함수를 호출해서 프로그램을 죽입니다. 또는 set_terminate()에 인수를 받지 않고 리턴값도 없는 콜백 함수를 포인터로 지정하는 방식으로 terminate_handler를 직접 구현해도 됩니다. terminate(), set_terminate(), terminate_handler는 모두 <exception> 헤더에 선언되어 있습니다.

사용법은 다음과 같습니다.

try {
    main(argc, argv);
}
catch (...) {
    if (std::terminate_handler != nullptr) {
        std::terminate_handler();
    }
    else {
        std::terminate();
    }
}

이렇게 하더라도 여기서 지정한 콜백 함수 또한 결국 에러를 무시하지 못하고 프로그램을 종료시킵니다. 그래도 최소한 종료 직전에 유용한 정보를 담은 에러 메세지를 출력할 기회는 있습니다.

예를 들어 다음 코드는 main()에서 커스텀 콜백 함수인 myTerminate()를 terminate_handler로 지정했습니다. 이 핸들러는 readIntegerFile()이 던지는 익셉션을 제대로 처리하지 않고 그냥 에러 메세지만 출력한 뒤 _Exit()를 호출해서 프로그램을 종료시킵니다. _Exit() 함수는 프로세스를 종료하는 방식을 표현하는 리턴값을 인수로 받습니다. 이렇게 지정한 값은 OS로 전달됩니다.

std::vector<int> readIntegerFile(std::string_view filename)
{
    std::ifstream inputStream{ filename.data() };
    if (inputStream.fail()) {
        // We failed to open the file: throw an exception
        throw std::invalid_argument{ "Unable to open the file." };
    }
    // Read the integers one-by-one and add them to a vector
    std::vector<int> integers;
    int temp;
    while (inputStream >> temp) {
        integers.push_back(temp);
    }

    if (!inputStream.eof()) {
        // We did not reach the end-of-file.
        // This means that some error occurred while reading the file.
        // Throw an exception.
        throw std::runtime_error{ "Error reading the file." };
    }
    return integers;
}

[[noreturn]] void myTerminate()
{
    std::cout << "Uncaught exception!\n";
    _Exit(1);
}

int main()
{
    set_terminate(myTerminate);

    const std::string filename{ "IntegerFile.txt" };
    std::vector<int> myInts{ readIntegerFile(filename) };

    for (const auto& element : myInts) {
        std::cout << element << " ";
    }
    std::cout << std::endl;
}

여기서 나오지는 않았지만, set_terminate() 함수는 새로운 terminate_handler를 지정하면 기존에 설정된 핸들러를 리턴합니다. terminate_handler는 프로그램 전체에서 접근할 수 있기 때문에 처리할 일이 끝나면 이를 리셋하는 것이 바람직합니다. 위 코드에서는 terminate_handler를 사용하는 다른 코드가 없기 때문에 리셋하지 않았습니다.

 

set_terminate()는 중요한 기능 중의 하나지만, 에러 처리에 가장 효과적인 수단은 아닙니다. 그보다는 처리할 익셉션을 try/catch 구문에 구체적으로 지정해서 꼭 필요한 익셉션만 제대로 처리하는 것이 바람직합니다.

상용 소프트웨어를 구현할 때는 프로그램이 종료되기 전에 크래시 덤프(crash dump)를 생성하기 위해 terminate_handler를 설정하는 경우가 많습니다. 이렇게 생성된 크래시 덤프를 디버거에 입력하면 프로그램에서 놓친 익셉션이나 문제의 발생 원인을 알아낼 수 있습니다. 단, 덤프를 생성하는 방법은 플랫폼마다 다르기 때문에 여기서 다루지는 않습니다.

 

2.6 noexcept Specifier

기본적으로 함수가 던질 수 있는 익셉션의 종류에는 제한이 없습니다. 하지만 함수에 noexcept 키워드를 지정해서 어떠한 익셉션도 던지지 않는다고 지정할 수는 있습니다. 예를 들어 앞에서 본 readIntegerFile() 함수에 noexcept를 지정하면 익셉션을 하나도 던지지 않습니다.

std::vector<int> readIntegerFile(std::string_view filename) noexcept

noexcept 키워드가 지정된 함수에 익셉션을 던지는 코드가 있으면 C++ 런타임은 terminate()를 호출해서 프로그램을 종료시킵니다.

 

파생 클래스에서 virtual 메소드를 오버라이드할 때 베이스 클래스에 정의된 메소드에 noexcept이 지정되지 않았더라도 오버라이드하는 메소드에 noexcept을 지정할 수 있습니다. 하지만, 그 반대로는 불가능합니다.

 

2.7 noexcept(expression) Specifier

noexcept (expression)은 주어진 표현식이 true를 리턴할 때만 noexcept로 마킹합니다. 즉, noexcept는 noexcept(true)와 같습니다.

 

2.8 noexcept(expression) Operator

noexcept(expression) 연산자는 만약 주어진 표현식에 noexcept가 마킹(noexcept or noexcept(expression))되어 있으면 true를 리턴합니다. 이에 대한 평가는 컴파일 시간에 이루어집니다.

 

예를 살펴보면 다음과 같습니다.

void f1() noexcept {}
void f2() noexcept(false) {}
void f3() noexcept(noexcept(f1())) {}
void f4() noexcept(noexcept(f2())) {}

int main()
{
    std::cout << noexcept(f1())
        << noexcept(f2())
        << noexcept(f3())
        << noexcept(f4());
}

위 코드를 실행하면 1010을 출력합니다.

noexcept(f1())은 f1()이 명시적으로 noexcept 지정자가 마킹되어 있으므로 true입니다. noexcept(f2())는 false인데, f2()가 noexcept(false)로 noexcept가 지정되지 않은 것과 같기 때문입니다. 마찬가지로 noexcept(f3())과 noexcept(f4())는 각각 true와 false가 됩니다.

 

2.9 Throw Lists

이전 버전의 C++에서는 함수나 메소드에 던질 수 있는 익셉션을 지정할 수 있었습니다. 이를 throw list 또는 exception specification(익셉션 명세)라고 부릅니다.

C++11에서는 exception specification 기능에 대한 서포트가 폐기되었고, C++17부터 완전히 삭제되었습니다. 단, noexcept와 throw()는 남아 있고, throw()는 실질적으로 noexcept와 같습니다. C++20부터는 throw()에 대한 서포트가 완전히 삭제되었습니다.

C++17부터 exception specification 기능이 완전히 삭제되었기 때문에 따로 설명하지는 않습니다. 사실 이전 버전에서도 exception specification 기능을 거의 사용하지 않았습니다. 그래도 간략히 소개하자면 앞서 작성한 readIntegerFile() 함수에 exception specification을 지정하려면 다음과 같이 수정하면 됩니다.

std::vector<int> readIntegerFile(std::string_view filename)
    std::throw(std::invalid_argument, std::runtime_error)
{
    // ... 나머지 코드 생략
}

함수가 exception specification에 없는 익셉션을 던지면 C++런타임은 std::unexpected()를 호출합니다. 이 메소드는 기본적으로 std::terminate()를 호출해 프로그램을 종료시킵니다.

 


3. Exceptions and Polymorphism

앞서 언급했듯이 던질 수 있는 익셉션 타입에는 제한이 없습니다. 하지만 대부분 클래스로 정의하며, 클래스로 정의하면 계층 구조를 형성할 수 있기 때문에 익셉션을 처리하는 코드에서 다형성을 활용할 수 있습니다.

 

3.1 The Standard Exception Hierarchy

위에서 살펴본 exception, runtime_error, invalid_argument는 모두 C++ 표준 익셉션 클래스 타입입니다. 아래 그림은 표준 익셉션 클래스의 계층 구조를 보여주고 있습니다.

C++ 표준 라이브러리에서 던지는 익셉션 객체의 클래스는 모두 이 계층 구조에 속합니다. 여기 나온 클래스는 모두 what() 메소드를 가지고 있습니다. 이 메소드는 익셉션을 표현하는 const char* 타입의 스트링을 리턴하며, 에러 메세지를 출력하는데 활용할 수 있습니다.

 

익셉션 클래스는 대부분 what() 메소드가 리턴할 스트링을 생성자의 인수로 지정해야 합니다. 베이스 클래스은 exception은 반드시 생성자에 이 값을 전달해야 합니다. 그래서 앞서 나온 예제 코드에서 runtime_error나 invalid_argument를 생성할 때 스트링 인수를 전달했습니다. 이번에는 readIntegerFile()에서 에러 메세지를 만들 때 파일 이름도 함께 표시하도록 수정해보겠습니다.

using namespace std::string_literals;
std::vector<int> readIntegerFile(std::string_view filename) noexcept
{
    std::ifstream inputStream{ filename.data() };
    if (inputStream.fail()) {
        // We failed to open the file: throw an exception
        const std::string error{ "Unable to open file "s + filename.data() };
        throw std::invalid_argument{ error };
    }
    // Read the integers one-by-one and add them to a vector
    std::vector<int> integers;
    int temp;
    while (inputStream >> temp) {
        integers.push_back(temp);
    }

    if (!inputStream.eof()) {
        // We did not reach the end-of-file.
        // This means that some error occurred while reading the file.
        // Throw an exception.
        const std::string error{ "Unable to read file "s + filename.data() };
        throw std::runtime_error{ error };
    }
    return integers;
}

 

3.2 Catching Exceptions in a Class Hierarchy

익셉션 타입을 클래스 계층으로 구성하면 catch 구문에서 다형성을 활용할 수 있습니다.

try {
    myInts = readIntegerFile(filename);
}
catch (const std::invalid_argument& e) {
    std::cerr << e.what() << std::endl;
    return 1;
}
catch (const std::runtime_error& e) {
    std::cerr << e.what() << std::endl;
    return 2;
}

예를 들어 main() 함수 내의 위 코드에서 2개의 catch 구문은 인수 타입만 다르고 동작은 같습니다. invalid_argument와 runtime_error는 모두 exception을 상속하기 때문에 두 catch문을 다음과 같이 exception 타입의 인수를 받는 하나의 catch 문으로 합칠 수 있습니다.

try {
    myInts = readIntegerFile(filename);
}
catch (const std::exception& e) {
    std::cerr << e.what() << std::endl;
    return 1;
}

이렇게 catch 구문이 인수를 exception 레퍼런스로 받으면 invalid_argument와 runtime_error뿐만 아니라 exception을 상속한 모든 파생 클래스 타입을 인수로 받을 수 있습니다. 단, 익셉션 계층에서 베이스로 올라갈수록 에러를 구체적으로 처리하기 힘들어집니다. 일반적으로 catch 문에서 처리할 추상화 수준에 최대한 맞게 익셉션을 지정하는 것이 바람직합니다.

 

다형성을 이용한 catch 구문이 여러 개라면 코드에 나온 순서대로 매칭됩니다. 다시 말해 가장 먼저 매칭되는 구문으로 결정됩니다. 앞에 나온 catch문이 뒤에 나온 catch문보다 추상적이라면 앞의 것을 먼저 선택합니다. 따라서 구체적인 타입을 뒤에 적으면 한 번도 실행되지 않을 수 있습니다. 예를 들어 readIntegerFile()에서 던지는 invalid_argument를 꼭 잡아야 하는데, 다른 예외도 놓치지 않도록 가장 포괄적인 exception을 받는 catch 문도 넣을 때 다음과 같이 구체적인 타입의 catch문을 앞에 적습니다.

try {
    myInts = readIntegerFile(filename);
}
catch (const std::invalid_argument& e) {
    std::cerr << e.what() << std::endl;
    return 1;
}
catch (const std::exception& e) {
    std::cerr << e.what() << std::endl;
    return 2;
}

 

3.3 Writing Your Own Exception Classes

익셉션 클래스를 직접 정의하면 다음의 두 가지 장점이 있습니다.

  • C++ 표준 라이브러리는 몇 가지 익셉션에 대해서만 제공합니다. 익셉션 클래스를 직접 정의하면 runtime_error처럼 광범위한 이름 대신 작성한 프로그램에서 발생하는 에러에 최대한 가까운 이름으로 표현할 수 있습니다.
  • 원하는 정보를 얼마든지 익셉션에 추가할 수 있습니다. 표준 라이브러리에서 제공하는 익셉션은 에러 스트링만 넣을 수 있습니다.

익셉션을 직접 정의할 때는 반드시 표준 exception 클래스를 직접 또는 간접적으로 상속하는 것이 좋습니다. 프로젝트 구성원이 이 원칙을 따르면 프로그램에서 발생하는 익셉션을 모두 exception을 상속하게 만들 수 있고, 이렇게 하면 에러 처리 코드에서 다형성을 이용하기 훨씬 쉽습니다.

 

예를 들어, invalid_argument와 runtime_error는 readIntegerFile()에서 발생하는 파일 열기와 읽기 에러를 구체적으로 표현하지 못합니다. 따라서 다음과 같이 구체적인 상황을 FileError 클래스로 표현하고, 이 클래스가 exception을 상속하도록 정의해서 파일 에러에 대한 클래스 계층을 구성하게 만들면 좋습니다.

class FileError : public std::exception
{
public:
    FileError(std::string filename) : m_filename{ std::move(filename) } {}
    const char* what() const noexcept override { return m_message.c_str(); }
    virtual const std::string& getFilename() const noexcept { return m_filename; }
protected:
    virtual void setMessage(std::string message) { m_message = std::move(message); }
private:
    std::string m_filename;
    std::string m_message;
};

직접 정의한 FileError를 표준 익셉션 계층에 속하도록 exception의 하위 클래스로 정의하면 좋습니다. exception을 상속하려면 what() 메소드를 오버라이드해야 합니다. 이 메소드는 앞에서 본 것처럼 const char* 타입의 스트링을 리턴합니다. 이 스트링은 익셉션 객체가 소멸하기 전까지 사용할 수 있습니다. 이 스트링을 FileError의 m_message 데이터 멤버 값으로 지정합니다. FileError의 파생 클래스는 protected setMessage() 메소드를 이용하여 이 메세지를 다른 값으로 설정할 수 있습니다. 이때 FileError 클래스에는 파일 이름을 담는 데이터 멤버와 이 값에 대한 public 접근자도 정의합니다.

 

readIntegerFile()에서 발생할 수 있는 예외 상황으로 파일이 열리지 않는 경우가 있습니다. 이를 위해 다음과 같이 FileError를 상속하는 FileOpenError 익셉션을 정의합니다.

class FileOpenError : public FileError
{
public:
    FileOpenError(std::string filename) : FileError{ std::move(filename) }
    {
        setMessage("Unable to open "s + getFilename());
    }
};

FileOpenError 익셉션은 파일 열기 에러를 표현하는 값을 m_message 스트링에 지정합니다.

 

readIntegerFile()을 실행할 때 파일을 읽을 수 없는 경우도 발생할 수 있습니다. 이럴 때는 what() 메소드가 에러를 발생한 파일 이름뿐만 아니라 줄 번호도 함께 알려주면 좋습니다. 따라서 FileError를 상속하는 FileReadError를 다음과 같이 정의합니다.

class FileReadError : public FileError
{
public:
    FileReadError(std::string filename, size_t lineNumber)
        : FileError{ std::move(filename) }, m_lineNumber{ lineNumber }
    {
        setMessage("Error reading "s + getFilename() + ", line "s + std::to_string(lineNumber));
    }
    virtual size_t getLineNumber() const noexcept { return m_lineNumber; }
private:
    size_t m_lineNumber{ 0 };
};

물론, 줄 번호를 정확히 표시하려면 readIntegerFile() 함수에서 정수를 읽을 때 현재 줄 번호도 추적하도록 수정해야 합니다. 새로 정의한 익셉션을 던지도록 코드를 수정하면 다음과 같습니다.

std::vector<int> readIntegerFile(std::string_view filename)
{
    std::ifstream inputStream{ filename.data() };
    if (inputStream.fail()) {
        // We failed to open the file: throw an exception
        throw FileOpenError{ filename.data() };
    }
    // Read the integers one-by-one and add them to a vector
    std::vector<int> integers;
    size_t lineNumber{ 0 };
    while (!inputStream.eof()) {
        // Read one line from the file
        std::string line;
        getline(inputStream, line);
        ++lineNumber;

        // Create a string stream out of the line
        std::istringstream lineStream{ line }; // in <sstream> header

        // Read the integers one-by-one and add them to the vector
        int temp;
        while (lineStream >> temp) {
            integers.push_back(temp);
        }
    }

    if (!inputStream.eof()) {
        // We did not reach the end-of-file.
        // This means that some error occurred while reading the file.
        // Throw an exception.
        throw FileReadError{ filename.data(), lineNumber };
    }
    return integers;
}

이제 readIntegerFile()을 호출할 때 FileError의 다형성을 이용하여 익셉션을 처리하도록 catch 구문을 다음과 같이 작성할 수 있습니다.

int main()
{
    const std::string filename{ "IntegerFile.txt" };
    std::vector<int> myInts;

    try {
        myInts = readIntegerFile(filename);
    }
    catch (const FileError& e) {
        std::cerr << e.what() << std::endl;
        return 1;
    }

    for (const auto& element : myInts) {
        std::cout << element << " ";
    }
    std::cout << std::endl;
}

익셉션으로 사용할 클래스를 정의할 때 적용하면 좋은 팁이 하나 있습니다. 익셉션이 발생하면 그 익셉션 객체를 이동 생성자나 복사 생성자로 이동하거나 복사하게 됩니다. 따라서 익셉션으로 사용할 클래스를 정의할 때는 반드시 객체를 복사하거나 이동할 수 있도록 만들어야 합니다. 다시 말해 동적 할당 메모리를 사용한다면 클래스에 소멸자뿐만 아니라 복사 생성자와 복사 대입 연산자 그리고 이동 생성자와 이동 대입 생성자도 함께 정의해야 합니다.

익셉션 객체는 최소 한 번 이상 이동하거나 복사됩니다.

만약 익셉션을 레퍼런스로 받지 않고 값으로 받으면 복사가 여러 번 발생할 수 있습니다.

 

Source Location

C++20 이전에는 전처리기 매크로를 통해 소스 코드에서의 위치 정보를 얻을 수 있었습니다.

추가로 모든 함수는 함수의 이름을 담고 있는 로컬로 정의된 static 문자 배열인 __func__을 가지고 있습니다.

 

C++20에서는 C 스타일의 전처리기 매크로와 __func__을 대체하는 기능을 std::source_location 클래스의 형태로 제공하며, 이는 <source_location>에 정의되어 있습니다. source_location 인스턴스는 다음의 접근자들을 가지고 있습니다.

static 메소드인 current()는 이 메소드가 호출된 소스 코드에서의 위치에 기반한 source_location 인스턴스를 생성합니다.

 

source_location 클래스는 로깅 목적으로 사용할 때 유용합니다. 이전에는 C 스타일 매크로를 사용해서 현재 파일 이름, 함수 이름, line number를 얻을 수 있었지만, C++20에서는 source_location을 사용해서 순수한 C++ 함수를 통해 이들을 얻을 수 있습니다.

예를 들면 다음과 같이 사용할 수 있습니다.

#include <iostream>
#include <string_view>
#include <source_location>
void logMessage(std::string_view message,
    const std::source_location& location = std::source_location::current())
{
    std::cout << location.file_name() << "(" << location.line() << "): "
        << location.function_name() << ": " << message << std::endl;
}

void foo()
{
    logMessage("Starting execution of foo().");
}

int main()
{
    foo();
}

logMessage()의 두 번째 파라미터는 source_location인데 기본값으로 static 메소드인 current()의 결과입니다. 여기서 이 트릭은 current()의 호출이 line 5에서 발생하는 것이 아니라 실제로는 logMessage()가 호출되는 line 13에서 발생합니다.

실제로 위 코드를 실행시켜보면 다음과 같이 출력됩니다.

 

source_location은 익셉션 클래스에서 예외가 발생한 위치를 저장하는 데 유용하게 사용될 수 있습니다.

예를 들면 다음과 같습니다.

class MyException : public std::exception
{
public:
    MyException(std::string message,
        std::source_location location = std::source_location::current())
        : m_message{ std::move(message) }
        , m_location{ std::move(location) } {}
    const char* what() const noexcept override { return m_message.c_str(); }
    virtual const std::source_location& where() const noexcept { return m_location; }
private:
    std::string m_message;
    std::source_location m_location;
};

void doSomething()
{
    throw MyException{ "Throwing MyException." };
}

int main()
{
    try {
        doSomething();
    }
    catch (const MyException& e) {
        const auto& location{ e.where() };
        std::cerr << "Caught: " << e.what() << " at line " << location.line() << " in " << location.function_name() << std::endl;
    }
}

위 코드를 실행하면 아래처럼 출력됩니다.

 

 

3.4 Nested Exceptions

앞서 발생한 익셉션을 처리하는 도중에 또 다른 에러가 발생해서 새로운 익셉션이 전달될 수 있습니다. 아쉽게도 이렇게 중간에 익셉션이 발생하면 현재 처리하고 있던 익셉션 정보가 사라집니다. 이를 해결하기 위해 C++은 먼저 잡은 익셉션을의 새로 발생한 익셉션의 문맥 안에 포함시키는 중첩된 익셉션(nested exception)이라는 기능을 제공합니다.

이 기능은, 예를 들어, 현재 구현하는 프로그램에서 A 타입 익셉션을 던지는 서드파티 라이브러리의 함수를 사용하는 데 작성 중인 프로그램에서는 B 타입 익셉션만 처리하고 만들고 싶을 때, 서드파티 라이브러리에서 발생하는 익셉션을 모두 B 타입 안으로 중첩시키는데 사용할 수 있습니다.

 

어떤 익셉션을 처리하는 catch 문에서 새로운 익셉션을 던지고 싶다면 std::throw_with_nested()를 사용하면 됩니다. 나중에 발생한 익셉션을 처리하는 catch문에서 먼저 발생했던 익셉션에 접근할 때는 dynamic_cast()를 이용하면 됩니다. 이때 먼저 발생한 익셉션은 nested_exception으로 표현합니다. 구체적인 예를 통해 살펴보겠습니다.

먼저 다음과 같이 exception을 상속하고, 생성자에서 스트링을 인수로 받는 MyException 클래스를 정의합니다.

class MyException : public std::exception
{
public:
    MyException(std::string message) : m_message{ std::move(message) } {}
    const char* what() const noexcept override { return m_message.c_str(); }
private:
    std::string m_message;
};

그리고 아래 코드에 나오는 doSomething() 함수에서 runtime_error를 던지는데 바로 다음에 나온 catch 핸들러가 이를 잡아서 처리합니다. 이 핸들러는 몇 가지 메세지를 작성하고 나서 새로운 익셉션을 던지는데, 이때 throw_with_nested() 함수를 이용하여 새로운 익셉션 안에 먼저 발생한 익셉션을 담아서 던집니다. 익셉션을 중첩시키는 작업은 자동으로 처리됩니다.

void doSomething()
{
    try {
        throw std::runtime_error{ "Throwing a runtime_error exception" };
    }
    catch (const std::runtime_error& e) {
        std::cout << __func__ << " caught a runtime_error\n";
        std::cout << __func__ << " throwing MyException\n";
        std::throw_with_nested(
            MyException{ "MyException with nested runtime_error" });
    }
}

아래 main() 함수는 중첩된 익셉션을 처리하는 방법을 보여줍니다. 여기서 doSomething() 함수를 호출하는 코드가 있고, 그 아래 MyException 익셉션을 처리하는 catch 핸들러가 나옵니다. 이 핸들러가 익셉션을 잡으면 메세지를 작성한 뒤 dynamic_cast()를 이용하여 현재 익셉션에 중첩된 익셉션에 접근합니다. 그 안에 중첩된 익셉션이 없다면 널 포인터를 리턴합니다. 중첩된 익셉션이 있다면 nested_exception의 rethrow_nested() 메소드를 호출해서 중첩된 익셉션을 다시 던집니다. 그러면 다른 try/catch 구문에서 이 익셉션을 처리할 수 있습니다.

int main()
{
    try {
        doSomething();
    }
    catch (const MyException& e) {
        std::cout << __func__ << " caught MyException: " << e.what() << std::endl;
        const auto* nested{ dynamic_cast<const std::nested_exception*>(&e) };
        if (nested) {
            try {
                nested->rethrow_nested();
            }
            catch (const std::runtime_error& e) {
                // handle nested exception
                std::cout << " Nested exception: " << e.what() << std::endl;
            }
        }
    }
}

이 코드를 실행한 결과는 다음과 같습니다.

앞에 나온 main() 함수는 dynamic_cast()를 이용하여 중첩된 익셉션이 있는지 확인했습니다. 이렇게 중첩된 익셉션을 확인하기 위해 dynamic_cast()를 호출할 일이 많기 때문에 이 작업을 수행하는 std::rethrow_if_nested()란 간단한 헬퍼 함수를 표준에 정의해두었습니다. 이 헬퍼 함수의 사용 방법은 다음과 같습니다.

int main()
{
    try {
        doSomething();
    }
    catch (const MyException& e) {
        std::cout << __func__ << " caught MyException: " << e.what() << std::endl;
        try {
            std::rethrow_if_nested(e);
        }
        catch (const std::runtime_error& e) {
            // handle nested exception
            std::cout << " Nested exception: " << e.what() << std::endl;
        }
    }
}

 


4. Rethrowing Exceptions

throw 키워드는 현재 발생한 익셉션을 다시 던질 때 사용합니다. 예를 들면 다음과 같습니다.

void g() { throw std::invalid_argument{ "Some exception" }; }

void f()
{
    try {
        g();
    }
    catch (const std::invalid_argument& e) {
        std::cout << "caught in f: " << e.what() << std::endl;
        throw;
    }
}

int main()
{
    try {
        f();
    }
    catch (const std::invalid_argument& e) {
        std::cout << "caught in main: " << e.what() << std::endl;
    }
}

이 코드를 실행한 결과는 다음과 같습니다.

여기서 throw e;와 같은 문장으로 익셉션을 다시 던지면 된다고 생각하기 쉽지만, 익셉션 객체에 대한 슬라이싱이 발생하기 때문에 그렇게 하면 안됩니다.

예를 들어 f()에서 std::exception을 잡고, main()에서 exception과 invalid_argument 익셉션을 모두 잡으려면 다음과 같이 수정해보도록 하겠습니다.

void g() { throw std::invalid_argument{ "Some exception" }; }

void f()
{
    try {
        g();
    }
    catch (const std::exception& e) {
        std::cout << "caught in f: " << e.what() << std::endl;
        throw;
    }
}

int main()
{
    try {
        f();
    }
    catch (const std::invalid_argument& e) {
        std::cout << "invalid_argument caught in main: " << e.what() << std::endl;
    }
    catch (const std::exception& e) {
        std::cout << "exception caught in main: " << e.what() << std::endl;
    }
}

여기서 invalid_argument가 exception을 상속하고 있씁니다. 따라서 이 코드를 실행하면 예상대로 다음과 같이 출력됩니다.

그런데 f()에서 throw; 문장을 다음과 같이 바꿔보겠습니다.

throw e;

그러면 실행 결과가 다음과 같이 나옵니다.

이렇게 하면 main()이 exception 객체를 잡긴 하는데 invalid_argument 객체는 아닙니다. throw e; 문장에서 슬라이싱이 발생해서 invalid_argument가 exception으로 되어버렸기 때문입니다.

익셉션을 다시 던질 때는 항상 throw;로 작성해야 합니다. e라는 익셉션을 다시 던지기 위해 throw e;로 작성하면 안됩니다.

 


다음 포스팅에 이어서 계속 진행하도록 하겠습니다.. !

댓글