[Advanced C++] 70. Exception (try, throw, catch), stack unwinding, Exception with function, Uncaught exception & catch-all-handler, 멤버 함수에서의 예외처리, 생성자에서의 예외처리, Exception class, 상속관계에서의 throw-catch, std::exception & std::runtime_error, 예외객체의 수명

dev.kelvin·2025년 7월 12일
1

Advanced C++

목록 보기
70/74
post-thumbnail

1. Exception

예외

프로그래밍을 하다보면 오류처리는 필수이다

가장 기본적인 오류처리로 함수를 구현할때 return값으로 처리하는 방법이 있다

    int findIndexByNum(const std::array<int, 5>& inArr, int num)
    {
        if (inArr.empty())
        {
            return -1; //예외처리
        }

        for (int index = 0; index < inArr.size(); ++index)
        {
            if (inArr.at(index) == num)
            {
                return index;
            }
        }

        return -1; //예외처리
    }

    int main()
    {
        std::array<int, 5> Arr{ 1, 2, 3, 4, 5 };
        int result{ findIndexByNum(Arr, 1) };

        std::cout << result; //0
    }

이렇게 원하는 결과값을 찾을 수 없는 상태라면 -1을 return하여 오류 처리를 했다

하지만 이러한 방법은 여러 단점들을 가지고 있다

  1. 반환값이 모호하다
    -1을 return했을때 이게 단순히 오류를 의미하는건지 아니면 valid한 값인지 알기 어렵다

  2. 함수는 하나의 값만 반환이 가능하다
    만약 결과값과 성공여부를 둘 다 반환하려면 어떻게 할까? 결과값은 return하고 성공여부는 out함수 매개변수를 사용해야 한다 -> 굉장히 불편하고 가독성이 떨어짐

  3. 오류 확인을 반복해야 한다
    함수 내부에서 오류가 발생할 가능성이 있는 부분을 계속해서 return해야 한다 (20개면 20개 전부 return처리를 해야함)

  4. 생성자와의 호환성
    생성자에서 오류가 발생한다면? 반환타입이 없기 때문에 값을 return할 수 없어서 이 방법을 사용할 수 없다

  5. 호출자에서 반환받은 값을 처리할 기능이 없다면 해당 오류가 무시될 수 있다
    만약 -1을 return받았는데 호출자가 이 값을 통해 예외처리를 할 수 없다면 해당 오류는 무시될 수 있다

위와 같은 단점들을 해결하기 위해 C++의 예외처리를 사용해야한다

예외처리는 throw, try, catch 키워드를 사용하여 구현된다

Throwing exception

throw는 예외나 오류가 발생했음을 알리는데 사용되는 키워드이다, 예외를 발생시키는 키워드다

throw뒤에 예외 발생 신호로 사용하고 싶은 데이터 타입의 값을 넣어주면 된다

	throw -1; //정수
    throw ENUM_INVALID_VALUE; //enum
    throw "Exception" //c-style 문자열
    throw Knight("Fatal Error"); //Knight 클래스 객체

이러한 throw들은 try블록 내부에 들어가게 된다

	try
    {
    	throw -1;
    }

try에는 해당 예외를 어떻게 처리할지는 정의하지 않는다

그렇다면 진짜 예외를 처리하는 부분은 어디일까? 바로 catch이다

catch키워드는 단일 데이터 타입의 예외를 처리하는 코드 블록을 정의한다

	catch (int x)
    {
    	std::cerr << "Catch int exception" << x << '\n';
    }

결국 try블록 안에 있는 throw로 던진 예외를 catch블록이 예외처리 하는 방식이 된다

따라서 try블록 다음에는 하나 이상의 catch블록이 반드시 와야한다 (여러개 가능)

만약 catch블록으로 예외처리가 되고 나면 바로 아래의 모든 catch블록 바로 다음 문장부터 코드가 실행된다

catch의 매개변수는 함수의 매개변수처럼 동작한다, 함수와 마찬가지로 해당 블록 내부에서만 사용이 가능하며 기본 타입들은 값으로 받아도 상관 없지만 객체 타입과 같은 경우에는 const&로 받아 복사를 피하고 객체 slicing을 피하는게 좋다

또한 매개변수를 사용하지 않을거라면 매개변수 이름을 작성하지 않아도 된다

	catch (int)
    {
    	//예외처리
    }

예외처리에 대해서는 타입 변환이 발생하지 않는다 (int <-> double간의 암시적 형변환으로 double값 예외가 발생했는데 매개변수가 int인 catch로 예외처리가 되지 않는다는 의미임)

    int main()
    {
        try
        {
            throw - 1;
        }

        catch (int)
        {
            std::cerr << "int exception" << '\n';
        }
        catch (double)
        {
            std::cerr << "double exception" << '\n';
        }
        catch (const std::string&)
        {
            std::cerr << "string exception" << '\n';
        }

        std::cout << "End main()" << '\n';
    }

따라서 catch는 handler라고 칭한다

만약 다음과 같은 코드가 있다면 어떤 결과값이 나올까?

    int main()
    {
        try
        {
            throw - 1;
            std::cout << "This line will not be executed" << '\n';
        }

        catch (int)
        {
            std::cerr << "int exception" << '\n';
        }

        std::cout << "End main()" << '\n';
    }

try에서 throw로 예외를 발생시키면 그 즉시 catch블록으로 jump하여 핸들링 되기 때문에 This line will not be executed는 절대 출력되지 않는다

입력을 받아 예외를 처리하는 코드도 살펴보자

	std::cout << "Enter num";
	double d{};
	std::cin >> d;

	try
	{
		if (d < 0.0)
		{
			throw "negative number exception";
		}

		std::cout << d << '\n';
	}

	catch (const char* inString)
	{
		std::cerr << inString << '\n';
	}

입력받은 숫자가 음수라면 throw C-style문자열로 예외를 발생시켜 바로 밑 catch로 들어가게 되고 양수라면 try블록의 std::cout으로 값이 출력되게 된다

throw로 발생시킨 예외를 catch블록에서 처리할때 catch블록이 비워져있어도 처리된 것으로 간주된다

일반적으로 catch블록에서는 오류를 로깅하거나 값을 return하거나 또 다른 예외를 try블록으로 throw하거나 치명적인 예외라면 프로그램을 종료시킬수도 있다

Exception, function, stack unwinding

try블록 내의 모든 throw는 감지되어 catch블록으로 핸들링 된다

그렇다면 try블록 내부에 함수 호출로 인하여 throw가 되면 어떻게 될까? 이또한 catch로 핸들링된다

따라서 try블록 내부에 직접 throw를 사용할 필요가 없다

    double Foo(double d)
    {
        if (d < 0.0)
        {
            throw "negative number exception";
        }

        return d;
    }

    int main()
    {
        std::cout << "Enter num";
        double d{};
        std::cin >> d;

        try
        {
            double tempDouble{ Foo(d) };

            std::cout << d << '\n';
        }

        catch (const char* inString)
        {
            std::cerr << inString << '\n';
        }
    }

만약 음수를 입력한다면 Foo()에서 throw가 발생하고 main의 try블록에서 throw를 감지하고 catch로 핸들링되게 된다

결론적으로 예외를 발생시키는건 함수에서 하지만 예외를 핸들링하는건 호출자에게 위임하는 형태가 된 것이다

이는 각 호출자마다 핸들링 시키는 방식이 다를 수 있기 때문에 호출자에게 해당 예외를 핸들링하는 권한을 위임하는게 좋기 때문이다

예외가 발생하면 프로그램은 현재 함수 내부에서 해당 예외가 핸들링 될 수 있는지 확인한다 (try블록 내부에서 throw가 되었고 catch블록이 있는지 확인 후 핸들링할 수 있는 catch블록인지 확인)

만약 해당 함수 내부에서 예외가 핸들링 될 수 있다면 즉시 처리한다

하지만 해당 함수 내부에서 핸들링 될 수 없다면 해당 함수의 호출자가 예외를 핸들링 할 수 있는지 확인한다 (해당 함수를 호출하는 부분이 try블록 내부에 있고 알맞는 catch가 있는지 확인)

이렇게 계속해서 호출자를 확인하여 핸들러를 찾는데 이러한 과정을 stack unwinding이라고 한다

이때 stack unwinding이 되면서 해당 함수를 빠져나가기 때문에 지역변수는 소멸된다

복잡한 케이스를 통해 다시 확인해보자

    #include <iostream>

    void D() { // C에 의해 호출됨
        std::cout << "Start D\n";
        std::cout << "D throwing int exception\n";
        throw -1;
        std::cout << "End D\n"; // 건너뜀
    }

    void C() { // B에 의해 호출됨
        std::cout << "Start C\n";
        D();
        std::cout << "End C\n"; // 건너뜀
    }

    void B() { // A에 의해 호출됨
        std::cout << "Start B\n";
        try {
            C();
        } catch (double) { // 잡히지 않음: 예외 타입 불일치
            std::cerr << "B caught double exception\n";
        }
        std::cout << "End B\n"; // 건너뜀
    }

    void A() { // main에 의해 호출됨
        std::cout << "Start A\n";
        try {
            B();
        } catch (int) { // 예외가 여기서 잡혀서 처리됨
            std::cerr << "A caught int exception\n";
        } catch (double) { // 호출되지 않음: 이전 catch에서 처리됨
            std::cerr << "A caught double exception\n";
        }
        // 예외가 처리된 후 실행은 여기서 계속됨
        std::cout << "End A\n";
    }

    int main() {
        std::cout << "Start main\n";
        try {
            A();
        } catch (int) { // 호출되지 않음: 예외가 A에서 처리됨
            std::cerr << "main caught int exception\n";
        }
        std::cout << "End main\n";
        return 0;
    }

결과는 다음과 같이 나오게 된다

    Start main
    Start A
    Start B
    Start C
    Start D
    D throwing int exception
    A caught int exception
    End A
    End main

결국 예외는 catch를 통해 핸들링 될때까지 stack unwinding이 되고 이 과정에서 각 함수의 지역변수들은 소멸된다


2. Uncaught exception & catch-all-handler

처리되지 않은 예외, 포괄 핸들러

함수에서 예외를 발생시키고 해당 함수에서 예외를 처리하지 않는다면 call stack 어딘가에서 이 예외를 처리할 것이라고 가정하는것이다

하지만 그 어떤 곳에서도 이 예외를 처리하지 않는다면 어떻게 될까?

만약 함수가 던진 예외에 대한 핸들러 (catch)를 찾을 수 없다면 std::terminate()가 호출되고 프로그램이 종료된다

(Unhandled exception 발생으로 크래쉬)

이때 call stack이 unwind될 수도 있고 그렇지 않을 수도 있다, 만약 call stack이 unwind되지 않는다면 지역변수 소멸도 되지 않기 때문에 해당 변수들이 소멸될 때 발생해야 하는 이벤트도 호출되지 않을 수 있다

예외가 처리되지 않아 크래시가 발생했을 때 call stack이 왜 unwind되지 않을 수 있는가? 예외가 처리되지 않은 상태에서 call stack이 전부 unwind된다면 callstack에 대한 모든 디버깅 데이터가 소멸되어 원인 파악이 힘들게 된다

따라서 예외는 반드시 핸들링해주는게 좋다

그렇다면 여기서 고민해야 할 점은 함수는 거의 모든 타입에 대한 예외를 발생시킬 수 있고 이러한 예외를 핸들링하지 않는다면 크래쉬가 발생할 수 있다, 그렇다면 모든 타입에 대한 예외를 핸들링하는 catch를 구현해야 하는것인가? 라는점이다

C++에는 위와 같은 문제를 쉽게 해결하기 위해서 catch-all-handler를 제공한다, 모든 타입을 명시하지 않고 ...을 사용한다

	try
	{
		throw 5;
	}
	catch (double d)
	{
		std::cout << "double handler" << std::endl;
	}
	catch (...)
	{
		std::cout << "catch all handler" << std::endl;
	}

이 throw 5예외는 catch(...)이라는 catch-all-handler에서 핸들링되어 catch all handler 로그가 발생하게 된다

이러한 catch-all-handler는 catch 블록의 가장 마지막에 위치해야 한다 (switch-case의 default와 비슷한 느낌)

이는 정확히 맞는 타입의 catch 핸들러가 예외를 핸들링할 수 있게 하기 위함이다

이러한 catch-all-handler는 main()에서 혹시나 처리되지 않은 예외가 있어 갑작스러운 프로그램 종료를 방지하고 싶을때 사용해도 좋다 (catch-all-handler로 남아있는 예외가 핸들링되고 call stack unwind가 된다)

분명 단점이 존재하는데 catch-all-handler는 처리되지 않은 예외를 모두 처리하기 때문에 call stack이 전부 풀려버려 디버깅 정보를 잃게 되고 어디서 잘못된 예외가 발생하는지 알기 힘들 수 있다

따라서 debug build에서는 catch-all-handler를 사용하지 않는게 조금 더 디버깅에 유리할 수 있다

    class DummyException // 인스턴스화할 수 없는 더미 클래스
    {
        DummyException() = delete;
    };

    int main()
    {
        try
        {
            throw 5;
        }
        catch (double d)
        {
            std::cout << "double handler" << std::endl;
        }
    #ifdef NDEBUG
        catch (...)
        {
            std::cout << "catch all handler" << std::endl;
        }
    #else
        catch (DummyException)
        {
        }
    #endif

    }

이렇게 #ifdef NDEBUG로 디버깅 모드가 아닐때만 catch-all-handler를 사용할 수 있다

이러한 매크로는 보통 release빌드에서 assert()와 같은 디버깅 코드를 비활성화 할 때 자주 사용한다, #ifdef NDEBUG는 release mode를 의미하고 #else나 #ifndef NDEBUG는 디버그 모드를 의미한다


3. 멤버 함수에서의 예외

멤버 함수에서의 예외

간단한 연산자 오버로딩을 한번 작성해보자

	int& IntArr::operator[](const int index)
    {
    	return data[index];
    }

이때 index가 data 배열의 유효한 인덱스여야 위 코드는 잘 동작하게 된다, 하지만 그러한 오류 체크 기능이 없다

그래서 이제까지 보통 assert를 이용하여 에러를 발생시켰다

	int& IntArr::operator[](const int index)
    {
    	assert(index >=0 && index < getDataLength());
    	return data[index];
    }

멤버함수도 일반함수와 마찬가지로 assert를 사용하지 않고 throw를 통해 예외를 발생시킬 수 있다

	int& IntArr::operator[](const int index)
    {
    	if (index < 0 || index >= getDataLength())
        {
        	throw index;
        }
        
    	return data[index];
    }

이렇게 조건에 맞지 않으면 멤버함수에서도 throw를 통해 예외를 발생시킬 수 있다

또한 생성자에서도 예외는 굉장히 유용하게 사용될 수 있다

예를들어 생성자가 실패할 때를 가정해보자 (객체 생성이 실패했음을 알릴 수 있다)

	class Member
    {
    public:
        Member()
        {
            std::cerr << "Member()\n";
        }

        ~Member()
        {
            std::cerr << "~Member()\n";
        }
    };

    class A
    {
    private:
        int m_x{};
        Member m_member;

    public:
        A(int x) : m_x{ x }
        {
            // 생성자 본문 실행 전 m_member가 먼저 생성됨
            if (x <= 0)
                throw 1;
        }

        ~A()
        {
            std::cerr << "~A\n"; // 객체 생성이 완료되지 않았으므로 호출되지 않음
        }
    };

    int main()
    {
        try
        {
            A a{ 0 }; // 생성자에서 예외 발생
        }
        catch (int)
        {
            std::cerr << "Oops\n";
        }

        return 0;
    }

클래스 A의 생성자에서 예외가 발생하면 A클래스 객체의 멤버들이 전부 소멸된다, 따라서 Member클래스의 생성자와 소멸자가 호출되고 Oops가 출력되게 된다

하지만 A클래스 객체는 생성자 도중에 예외가 발생하여 생성이 완료되지 않았기 때문에 소멸자가 호출되지 않는다

이 과정에서 A클래스 객체의 생성자에서 예외가 발생하며 멤버 데이터가 전부 소멸되는 것이 바로 RAII가 강력히 권장되는 이유이다 (resource 사용을 자동 기간 (동적 할당되지 않은 객체)의 생명주기에 연결하는 프로그래밍 기법)

Exception class

기본 타입들로 예외를 발생시키면 그 의미가 모호해질 수 있다

    class IntArray
    {
    private:
        int m_data[10];

    public:
        int& operator[](int index)
        {
            if (index < 0 || index >= 10)
            {
                throw -1; 
            }
            return m_data[index];
        }
    };

여기서 던지는 throw -1은 배열의 index에러인지 무슨 에러인지 확실하지가 않고 모호하다

따라서 조금 더 정확한 예외를 던지기 위해 Exception class를 던지는게 좋다

    class ArrayException
    {
    private:
        std::string m_error;
    public:
        ArrayException(std::string_view error)
            : m_error{ error }
        {
        }

        const std::string& getError() const { return m_error; }
    };

    class IntArray
    {
    private:
        int m_data[3]{};
    public:
        IntArray() {}

        int getLength() const { return 3; }

        int& operator[](const int index)
        {
            if (index < 0 || index >= getLength())
                throw ArrayException{ "Invalid index" };

            return m_data[index];
        }
    };

    int main()
    {
        IntArray array;

        try
        {
            int value{ array[5] };
        }
        catch (const ArrayException& exception) // 클래스 예외는 const 참조로 받음
        {
            std::cerr << "An array exception occurred (" << exception.getError() << ")\n";
        }

        return 0;
    }

예외 클래스인 ArrayException에는 자세한 에러의 원인인 std::string타입의 m_error변수가 있다

IntArray클래스의 operator[] 오버로딩에는 index가 0보다 작거나 length보다 크면 해당 예외 클래스 타입의 객체를 throw하여 예외를 발생시킨다

main()에서 array[5]를 했기 때문에 예외가 발생하고 catch (const ArrayException&)을 통해 예외 클래스를 받아 어떤 예외가 있었는지 자세히 확인이 가능해졌다

이러한 예외 클래스도 다른 클래스 객체와 마찬가지로 catch에서 파라미터로 받을때 값타입 대신 참조타입으로 받아 불필요한 복사를 방지하고 상속 관계일때는 Object Slicing을 방지하는게 좋다

상속관계에서의 throw

클래스 타입 객체를 throw로 던질 수 있다면 상속관계는 어떻게 catch될까?

	class Base
    {
    public:
        Base() = default;
    };

    class Derived : public Base
    {
    public:
        Derived() = default;
    };

    int main()
    {
        try
        {
            throw Derived();
        }
        catch (const Base& b)
        {
            std::cout << "Caught a Base reference from Derived." << std::endl;
        }
        catch (const Derived& d)
        {
            std::cout << "Caught a Derived reference." << std::endl;
        }

        return 0;
    }

위 결과는 catch (const Base& b)가 핸들링하여 Caught a Base reference...가 출력되게 된다

이는 암시적 변환이 아니기 때문에 기존에 기본 타입 예외 핸들링에서 암시적 변환이 불가능하다는 점에 반발하지 않는 개념이다 (업캐스팅)

Derived() 객체는 Base클래스 타입을 부모로 하기 때문에 catch에 걸린다, 따라서 밑의 catch (const Derived& d)에는 가지도 못하고 예외가 처리된다

catch (const Derived&)로 핸들링 시키고 싶다면 블록의 순서를 변경해야 한다

std::exception

STL의 많은 클래스와 연산자는 무언가 실패 시 예외 클래스를 던진다

예를들면 operator new는 충분한 메모리 할당이 불가능 할 때 std::bad_alloc 이라는 예외 클래스를 throw하고 dynamic_cast는 실패하게 되면 std::bad_cast라는 예외 클래스를 throw한다

(C++20기준 28개 정도의 예외 클래스가 존재하며 추가되고 있음)

이러한 모든 예외 클래스들은 < exception >헤더에 있는 std::exception이라는 클래스에서 파생되었다, 한마디로 C++의 모든 예외 클래스들은 std::exception클래스를 base클래스로 한다

따라서 프로그래머는 std::exception타입으로 다양한 STL에서 throw하는 예외를 핸들링 할 수 있다

	#include <exception> // std::exception
	#include <iostream>
	#include <string>
	#include <limits>    // std::numeric_limits

    try
    {
        std::string s;
        s.resize(std::numeric_limits<std::size_t>::max()); // 너무 큰 값으로 string resize가 되어 예외 발생
    }
    catch (const std::exception& exception)
    {
        std::cerr << "Standard exception: " << exception.what() << '\n';
    }

std::exception의 what()이라는 virtual memeber function을 통해 어떤 예외가 발생했는지 알 수 있다 (위 코드에서는 string too long 예외가 발생한다)

what()은 C-style 문자열을 반환하고 각각의 예외 클래스들은 이 what()을 override하여 각 예외에 대한 설명을 return하게 한다

what()을 통해 나오는 예외 설명은 컴파일러마다 다를 수 있기 때문에 비교문에 적용하면 안된다

만약 특정 타입의 예외만 특수화 시키고 싶다면 그 예외를 catch로 핸들링하면 된다

    try
    {
        std::string s;
        s.resize(std::numeric_limits<std::size_t>::max()); // std::length_error 또는 할당 예외 발생
    }
    catch (const std::length_error& exception)
    {
        std::cerr << "You ran out of memory!" << '\n';
    }
    catch (const std::exception& exception)
    {
        std::cerr << "Standard exception: " << exception.what() << '\n';
    }

위 코드는 catch (const std::length_error&)로 핸들링 되어 특수화 되었다

std::exception은 모든 예외 클래스의 Base클래스이다, 이러한 std::exception을 직접적으로 throw하는건 절대 권장하지 않는다

그 대신 < stdexcept >헤더에 포함되어 있는 std::runtime_error를 사용하자

	#include <stdexcept> // std::runtime_error
    
    int main()
    {
        try
        {
            throw std::runtime_error("runtime error");
        }
        catch (const std::exception& exception)
        {
            std::cerr << "Standard exception: " << exception.what() << '\n';
        }

        return 0;
    }

결과는 std::runtime_error("")에 쓴 값이 what()을 통해 나오게 된다

이러한 std::exception이나 std::runtime_error를 통해 직접 파생 예외 클래스를 구현할 수 있다

    class FooException : public std::exception
    {
    public:
        FooException(std::string_view message) : errorMessage{ message }
        {
        }

        const char* what() const noexcept override
        {
            return "FooException occurred";
        }

    private:
        std::string errorMessage{};
    };

    int main()
    {
        try
        {
            throw FooException("Test Exception");
        }
        catch (const FooException& exception)
        {
            std::cerr << "Foo exception: " << exception.what() << '\n';
        }

        return 0;
    }

위에서 설명한 what()은 virtual member function이기 때문에 원하는 에러 메세지를 char*로 return하는 함수로 override해서 사용하면 된다 (noexcept가 반드시 있어야 한다)

이보다 편한게 바로 std::runtime_error이다, std::runtime_error는 std::string처리 기능을 가지고 있기 때문이다

    class FooException : public std::runtime_error
    {
    public:
        FooException(const std::string& message) : std::runtime_error{ message }
        {
        }
    };

이렇게 std::runtime_error{ message }로 std::string을 인자로 받는 생성자를 이용하여 바로 예외 메세지를 넣을 수 있다

예외 수명

예외가 throw로 인해 발생하게 되고 throw되는 객체는 보통 stack영역에 올라가 있는 임시 객체나 지역 변수이다, 이때 예외가 발생하게 되면 핸들링하는 catch를 찾기 위해 stack unwind가 되고 이 과정에서 함수의 지역 변수가 소멸된다고 정리했다, 그렇다면 이렇게 throw로 넘기는 예외 객체는 어떻게 소멸하지 않는걸까?

예외가 throw를 통해 발생하게 되면 컴파일러는 예외 객체의 사본을 call stack외부의 메모리 공간에 만든다, 따라서 예외 객체는 call stack unwind로부터 소멸되지 않는다, 그렇기 때문에 예외 객체는 복사 가능해야한다, 단 이때 사본을 만들지 않고 move처리를 할 수도 있다

만약 복사 생성자가 delete된 객체라면 throw로 예외 객체를 넘길 수 없다

    class Foo
    {
    public:
        Foo() = default;
        Foo(const Foo& InFoo) = delete; //복사생성자 delete
    };

    int main()
    {
        Foo f1{};
        try
        {
            throw f1; //compile error
        }
        catch (...)
        {

        }

        return 0;
    }

또한 throw되는 예외 객체들은 stack영역에 올라가는 객체의 포인터나 참조를 가지면 안된다, stack unwind를 통해 로컬변수가 소멸되기 때문에 throw되는 예외 객체가 dangling 상태가 될 수 있다

profile
GameDeveloper🎮 Dev C++, DataStructure, Algorithm, UE5, Assembly🛠, Git/Perforce🌏

0개의 댓글