[Advanced C++] 71. Rethrowing exception, Function try block, 예외의 위험성 & 단점, Exception speicification, noexcept, std::move_if_noexcept

dev.kelvin·2025년 7월 15일
1

Advanced C++

목록 보기
71/74
post-thumbnail

1. Rethrowing exception

Rethrowing exception

예외를 catch했지만 해당 catch에서 예외를 완전히 처리하고 싶지 않거나 처리할 수 없을경우에 예외를 다시 던질 수 있다

예를 들면 다음과 같다

Database* createDatabase(std::string filename) 
{
    Database* d{};

    try
    {
        d = new Database{};
        d->open(filename); // 실패 시 int 예외를 던진다고 가정
        return d;
    }
    catch (int exception)
    {
        // 데이터베이스 생성 실패했기 때문에 delete로 동적할당된 메모리 해제
        delete d;
        // 전역 로그 파일에 오류 기록
        g_log.logError("Creation of Database failed");
    }

    return nullptr;
}

이렇게 반환형이 있는 함수라면 호출자에게 해당 작업이 실패했음을 알리기 쉽다

그렇다면 반환값으로 오류를 알리기 힘든 상황이라면?

int getIntValueFromDatabase(Database* d, std::string table, std::string key) 
{
    assert(d);

    try
    {
        return d->getIntValue(table, key); // 실패 시 int 예외를 던짐
    }
    catch (int exception)
    {
        // 전역 로그 파일에 오류 기록
        g_log.logError("getIntValueFromDatabase failed");
    }
}

int를 반환하는 함수이기 때문에 호출자에게 해당 함수가 성공했는지 실패했는지 알리기 힘들다 (단순 정수값으로 valid, invalid를 판단하기 힘들다, -1을 return해도 호출자 입장에서 -1이 유효한 값 일수도 있다)

이때 catch에서는 단순히 로그 파일에 오류를 기록할 뿐 그 어떠한 처리도 진행하지 않는다, 이럴때 예외를 다시 발생시키는 방식을 사용한다

int getIntValueFromDatabase(Database* d, std::string table, std::string key) 
{
    assert(d);

    try
    {
        return d->getIntValue(table, key); // 실패 시 int 예외를 던짐
    }
    catch (int exception)
    {
        // 전역 로그 파일에 오류 기록
        g_log.logError("getIntValueFromDatabase failed");
        
        throw 'q';
    }
}

try에서 throw가 발생하고 catch(int)로 핸들링된 후 다시 throw하여 호출자가 최종적으로 이 예외를 핸들링 하게 만드는 로직이다

catch에서 throw한 예외는 어떤 타입도 가능하다 (catch()인자로 들어온 예외 타입과 동일하게 맞출 필요가 없다)

그렇다면 catch()의 인자로 들어온 예외를 그대로 다시 throw하는건 어떨까?

결론적으로 동작은 하지만 몇 가지 단점이 존재한다

int getIntValueFromDatabase(Database* d, std::string table, std::string key) 
{
    assert(d);

    try
    {
        return d->getIntValue(table, key); // 실패 시 int 예외를 던짐
    }
    catch (int exception)
    {
        // 전역 로그 파일에 오류 기록
        g_log.logError("getIntValueFromDatabase failed");
        
        throw exception;
    }
}

이는 인자로 들어온 예외와 정확히 동일한 예외를 throw하는게 아니다, exception이라는 매개변수를 복사 초기화 한 복사본을 throw하는것이다 (물론 컴파일러가 복사 생략을 할 수 있지만 그렇지 않다면 오버헤드가 발생할 수 있다)

하지만 이보다 객체 slicing이 발생하는게 더 큰 문제가 된다

class Base
{
public:
	Base() = default;
	virtual void print() const
	{
		std::cout << "Base class print function called." << std::endl;
	}
};

class Derived : public Base
{
public:
	Derived() = default;
	void print() const override
	{
		std::cout << "Derived class print function called." << std::endl;
	}
};

int main()
{
	try
	{
		try
		{
			throw Derived();
		}
		catch (const Base& b)
		{
			b.print();

			throw b;
		}
	}
	catch (const Base& b)
	{
		b.print();
	}

	return 0;
}

가장 안쪽의 try catch 블록부터 살펴보면 Derived 클래스 타입의 임시객체를 생성하여 throw로 예외 객체를 던지고 Base&로 catch하여 핸들링 하였다

값 타입이 아닌 참조 타입으로 받았기 때문에 b.print()는 virtual function이기 때문에 Derived::print()가 호출된다

이때 throw b를 하게 되면 Base타입으로 복사 초기화 되어 Derived클래스의 데이터를 잃는 객체 slicing이 발생하게 된다

따라서 가장 밑의 catch블록에서의 b.print()는 Base::print()가 호출되게 되는것이다

여기서 알 수 있는점은 throw가 될 때 예외 객체는 복사 초기화 되어 던져진다는 점이다

그렇다면 catch()에서 받은 예외를 그대로 다시 throw하는 옳은 방법은 어떤 방법일까?

바로 throw 시 아무것도 작성하지 않는것이다

int main()
{
	try
	{
		try
		{
			throw Derived();
		}
		catch (const Base& b)
		{
			b.print();

			throw;
		}
	}
	catch (const Base& b)
	{
		b.print();
	}

	return 0;
}

이렇게 하면 인자로 들어온 예외 객체의 사본이 아닌 예외 객체 그 자체를 throw하는 것이기 때문에 불필요한 복사가 발생하여 오버헤드가 발생하거나 객체 slicing이 발생하지 않는다


2. Function try block

함수 try 블록

만약 다음과 같이 멤버 초기화 리스트에서 발생한 예외를 생성자에서 핸들링하고 싶다면 어떻게 할까?

	class Base
    {
    public:
        Base(int inValue) : value{inValue}
        {
            if (inValue < 0)
            {
                throw -1;
            }
        }

    private:
        int value{};
    };

    class Derived : public Base
    {
    public:
        Derived(int inValue) : Base{ inValue } //Base(int)생성자 호출
        {
            //여기서 Base(int)생성자에서 발생한 예외를 처리하고 싶다면?
        }

    };

이럴때는 일반적인 try-catch를 사용할 수 없고 함수 try block을 사용해줘야 한다

    class Base
    {
    public:
        Base(int inValue) : value{inValue}
        {
            if (inValue < 0)
            {
                throw -1;
            }
        }

    private:
        int value{};
    };

    class Derived : public Base
    {
    public:
    	//functino try block
        Derived(int inValue) try : Base{ inValue }
        {
            //여기서 Base(int)생성자에서 발생한 예외를 처리하고 싶다면?
        }
        catch (int exception)
        {
            std::cout << "Base 생성자에서 예외 발생: " << std::endl;

            throw;
        }
    };

    int main()
    {
        try
        {
            Derived d{ -1 };
        }
        catch (int exception)
        {
            std::cout << "예외 처리: " << exception << std::endl;
        }

        return 0;
    }

Derived클래스의 생성자의 멤버 초기화 리스트 부분에서 try가 추가되어 해당 멤버 초기화 리스트에서 예외가 발생한다면 밑의 catch(int exception)으로 핸들링되게 된다

try가 : Base{ inValue } 앞에 붙었기 때문에 해당 지점부터 함수 끝까지 모든것을 try block안에 있는걸로 간주한다
(따라서 Derived(int inValue) 생성자 본문에서의 예외도 바로 밑의 catch로 핸들링 할 수 있다, 이 덕분에 부모, 자식 클래스 두개의 생성자에서 발생하는 예외를 모두 핸들링 할 수 있는 장점이 있음)

생성자의 함수 레벨 catch의 제약사항

앞서 정리한 일반 catch 블록에서는 새로운 예외를 throw하거나 기존의 예외를 throw하거나 아니면 예외를 그냥 처리해버릴 수 있다

하지만 생성자의 함수 레벨 catch 블록에서는 반드시 새로운 예외를 throw하거나 기존 예외를 throw해야 한다 (여기서 예외 처리가 불가능하다, return도 불가능)

이러한 catch블록의 끝에 도달하면 암시적으로 예외가 다시 throw된다 (따라서 throw를 따로 안해도 동일하게 동작함 -> 하지만 명시적으로 throw하자)

이는 생성자의 함수 레벨 catch에만 해당한다, 다른 일반 함수의 함수 레벨 catch블록은 해당되지 않는다

일반 함수의 함수 레벨 catch블록의 끝에 도달하면 void함수는 예외를 해결한걸로 간주하지만 값을 return하는 함수라면 undefined behavior가 발생할 수 있다

int problematicFunction()
try
{
    throw 1;
    return 10; // 이 줄은 절대 실행되지 않음
}
catch (int e)
{
    std::cerr << "problematicFunction에서 예외 잡음\n";
    
    //return을 하지 않으면 undefined behavior가 발생할 수 있음, 반환하는값이 없어서
    //return 0;
}

따라서 catch블록은 명시적으로 throw나 return등을 사용하는게 좋다

이러한 함수 레벨의 try catch는 생성자에서의 멤버 초기화 리스트 예외 발생을 핸들링할 때 주로 사용한다 (일반 함수에서는 잘 사용하지 않음)


3. 예외의 위험성 & 단점

예외의 위험성 & 단점

예외를 발생시킬때 리소스를 clean up하면 안된다

다음과 같은 예시 코드를 살펴보자

	try 
    {
      openFile(filename);
      writeFile(filename, data);
      closeFile(filename);
  	} 
    catch (const FileException& exception) 
    {
      	std::cerr << "Failed to write to file: " << exception.what() << '\n';
  	}

여기서는 closeFile()로 file 리소스를 cleanup해주는 함수를 try블록에서 호출하고 있다, 단 이때 openFile이나 writeFile에서 exception이 throw된다면 closeFile()은 실행되지 않은채 catch블록으로 jump하게 된다

따라서 try블록에서 리소스 해제를 하는 코드를 사용해도 실질적으로 호출되지 않을 가능성이 존재한다는 의미이다, 곧 메모리 누수로 이어질 수 있기때문에 조심해야 한다

이러한 경우는 동적 메모리 할당을 할 때 자주 발생한다

    try 
    {
        auto* john { new Person{ "John", 18, PERSON_MALE } };
        processPerson(john);
        delete john;
    } 
    catch (const PersonException& exception) {
        std::cerr << "Failed to process person: " << exception.what() << '\n';
    }

위 코드에서 processPerson()이 exception을 throw한다면 밑의 delete john이 호출되지 않아 heap메모리에 동적할당한 john은 해제되지 않아 메모리 누수가 발생한다

이때 john은 try블록 내부에 선언된 지역변수이기 때문에 해당 블록 외부에서 접근할 수 없어 메모리 해제가 불가능해진다

이럴때는 어떻게 해결하는게 좋을까?

우선 동적할당한 힙 메모리 주소를 가리키는 포인터 변수를 try블록 외부에 선언하고 try-catch 밑에서 delete하는 방법이 있다

	Foo* john{ nullptr };
    
    try 
    {
        john = new Person{ "John", 18, PERSON_MALE };
        processPerson(john);
    } 
    catch (const PersonException& exception) {
        std::cerr << "Failed to process person: " << exception.what() << '\n';
    }
    
    delete john;

이러면 john은 try블록 내부에 선언되어있지 않기 때문에 try-catch구문 밑에서 해제가 가능해진다

이 방식보다 더 권장되는 방식은 스마트포인터 클래스 객체를 사용하는것이다

	try 
    {
        std::unique_ptr<Foo> john{ std::make_unique<Foo>("john", 18, PERSON_MALE) };
        processPerson(john.get()); //unique_ptr이 관리하는 리소스 get
    } 
    catch (const PersonException& exception) {
        std::cerr << "Failed to process person: " << exception.what() << '\n';
    }

std::unique_ptr을 사용하여 해당 스마트 포인터 객체가 소멸되면 내부 리소스도 같이 해제시키는 방식으로 사용했다

이것이 바로 C++의 핵심 디자인 패턴인 RAII이다 (john이라는 unique_ptr객체가 생성되고 힙 메모리에 동적할당된 Foo타입 객체의 소유권을 가진다, 이때 john이 소멸되면 소유권을 가지고 있는 객체의 메모리도 같이 해제한다)

두번째로 조심해야 하는건 소멸자에서 예외를 던지면 안된다는 점이다

위에서 생성자에서 예외를 던져 해당 객체의 생성이 실패했음을 알렸지만 소멸자에서는 이러한 방식을 사용하면 안된다

이유는 stack unwind때문이다

만약 try블록에서 예외가 발생해서 핸들링할 catch블록을 찾기위해 stack unwind가 발생했다고 가정해보자, 이러한 과정에서 지역변수는 소멸되고 소멸자가 호출된다, 근데 소멸자에서 예외가 발생하면? stack unwind를 진행할지 아니면 해당 예외를 처리할지 알 수 없는 상황에 놓이게 되어 std::terminate가 호출되고 프로그램이 종료된다

따라서 소멸자는 항상 noexcept가 되어야 한다

마지막으로 예외는 약간의 성능 비용을 감수해야 한다

try에서 예외가 발생하고 이를 핸들링하는 catch블록을 찾으로 stack unwind가 될 때 성능 비용이 발생하게 된다, 이는 단순 if문보다 더 복잡하고 느리다

따라서 예외는 자주 발생하지 않는 심각한 오류에만 사용하는걸 권장한다

예를들면 다음과 같다

  1. 오류가 심각하여 실행을 계속할 수 없을때
  2. 오류가 발생한 지점에서 해당 오류를 해결할 수 없을때
  3. caller에게 해당 오류 코드를 return할 방법이 딱히 없을때

따라서 return으로 오류 발생 여부를 알리는 방식과 예외를 발생시키는 방식을 잘 선택해서 사용해야 한다


4. Exception specification, noexcept

Exception specification (예외 명세)

일반적인 함수 선언만 보고는 해당 함수가 예외를 발생시키는 함수인지 아닌지 알 수 없다

이는 생각보다 중요하게 작용할 수 있는데, 위의 소멸자에서 Foo()라는 함수를 호출한다고 했을 때 해당 Foo()가 예외를 발생시키게 되면 크래시가 나버린다

따라서 소멸자에서는 예외를 발생시키지 않는 함수만 호출해야 하는데 이를 함수 선언만 보고는 판단하기 힘들다

주석을 통해 해당 함수가 예외를 발생시키는지, 안 시키는지 명세하는것도 괜찮지만 주석은 코드가 변경될 때 같이 변경되지 않을 수 있으며 컴파일러의 강제성도 없다

따라서 우리는 noexcept 지정자를 사용하여 예외 명세를 처리한다

noexcept

noexcept지정자를 함수에 사용하게 되면 해당 함수는 절대 외부로 예외를 발생시키지 않는 함수가 된다

	void Foo() noexcept;

이때 주의해야할 점은 해당 함수 내부에서 예외를 던지거나 예외를 던질 가능성이 있는 함수 호출을 아예 막지는 않는다, noexcept함수 내부에서 예외를 catch하여 핸들링하고 해당 예외가 외부로 나가지 않는다면 허용된다

exception함수에서 처리되지 않은 예외가 함수 외부로 throw되려고 하고 외부에 핸들러 catch가 있어도 std::terminate가 호출되어 바로 프로그램을 종료시킨다 (이때 std::terminate가 호출되면 stack unwind가 완벽하게 보장되지 않아 객체의 정상적 소멸이 안될 수 있다)

    void FunctionFoo() noexcept
    {
        try
        {
            throw - 1;
        }
        catch (int)
        {
            std::cerr << "FunctionFoo에서 예외 못 잡음 외부로 throw\n";

            throw; //이걸 안하면 noexcept라도 함수 내부에서 예외를 던지고 핸들링이 가능하다
        }
    }

    int main() 
    {
        try
        {
            FunctionFoo();
        }
        catch (int)
        {
            std::cerr << "main에서 예외 잡음: " << -1 << "\n";
        }

        return 0;
    }

위 코드는 noexcept함수에서 외부로 예외를 발생시켰기 때문에 크래시가 나게 된다

물론 noexcept함수 내부에서 예외를 외부로 throw하지만 않는다면 사용이 가능하지만 noexcept함수 내부에서는 예외를 발생시키거나 예외를 처리하는 로직은 사용하지 않는게 좋다 (심지어 예외를 발생시킬만한 함수 호출도 자제)

추가로 예외 명세만 다른 함수는 오버로딩이 불가능하다 (반환형만 다른것도 오버로딩 불가능한것과 마찬가지임)

noexcept 매개변수

noexcept지정자는 매개변수를 bool값으로 받을 수 있다, true로 하게 되면 해당 함수는 noexcept적용, false로 하게 되면 해당 함수는 noexcept를 적용하지 않는다

이는 보통 템플릿 함수에서 자주 사용된다

noexcept 연산자

noexcept는 표현식에서도 사용이 가능하다, 이때 인자를 표현식으로 받아 컴파일러는 해당 표현식이 예외를 던진다면 false, 그렇지 않다면 true를 return한다

    void FunctionFoo()
    {
        throw - 1;
    }

    int main()
    {
        bool b1{ noexcept(FunctionFoo()) }; //false
        bool b2{ noexcept(1 + 2) }; //true

        return 0;
    }

이때 입력받은 표현식을 실제로 실행하지는 않고 단순히 bool값만 return한다

이는 예외 안전성 보장에 사용할 수 있다

예외 안전성 보장이란 예외가 발생했을 때 함수나 클래스가 어떻게 동작할지에 대한 계약이다

  1. 보장 없음 : 예외가 던져져도 어떠한 보장도 없다, 프로그램을 사용할 수 없을 수 있다 (위험)
  2. 기본 보장 : 예외가 던져져도 메모리 누수와 같은 심각한 문제는 일으키지 않는다, 프로그램은 계속 사용할 수 있는 상태
  3. 강력 보장 : All or Nothing으로 함수가 정상적으로 성공하거나 or 함수에서 중간에 예외가 발생하면 모든것을 함수 실행전으로 되돌려 놓는다, 데이터의 어중간한 변경이 남지 않는다 (생성자는 이 강력 보장을 준수해야 한다 (객체 생성이 실패해도 프로그램에 큰 영향을 주지 않음))
  4. 예외를 던지지 않음, 실패하지 않음을 보장 : 함수가 절대 예외를 던지지 않는다고 보장하는 수준이다

noexcept 사용처

대부분의 사용자 정의 함수들은 예외를 던질 가능성이 있는 함수이다, 따라서 정말 확실하게 예외를 던지지 않는 함수에만 noexcept지정자를 사용해야 한다

예를들면 소멸자와 같이 예외를 절대 발생시켜서는 안되는 함수에 사용한다 (소멸자에서 noexcept함수는 안전하게 호출된다)

또한 noexcept함수는 성능상 아주 약간 유리할 수 있다, noexcept함수는 외부로 예외를 throw하지 않는다고 보장했기 때문에 stack unwind를 할 필요가 없기 때문이다

그리고 함수가 noexcept임을 알면 더 효율적인 구현을 사용할 수 있다

예를들어 std::vector와 같은 STL 컨테이너들은 함수의 noexcept를 인식하고 move를 사용할지 copy를 사용할지 정한다

만약 element타입의 class의 이동 생성자가 noexcept라면 안전하다고 판단하고 move를 선택하지만 이동 생성자가 noexcept가 아니라면 copy를 수행한다 (성능상 차이가 존재함)

따라서 이동 생성자, 이동 할당 연산자, swap 함수는 noexcept로 만들어야 한다


5. std::move_if_noexcept

std::move_if_noexcept

특정 객체를 복사하는 중에 복사가 실패하는 경우를 생각해보자, 이렇게 복사가 실패하더라도 원본 객체는 절대 손상되지 않는다 (사본을 만드는데 원본 객체의 수정이 들어가지 않기 때문에)

위와 같은 케이스에서 강력 보장이 지켜지고 있는것이다

그렇다면 copy가 아닌 move는 어떨까? move는 해당 리소스의 소유권을 원본 객체에서 다른 객체로 이전하는 것이다, 만약 move연산이 예외가 발생하여 중단되었다면 원본 객체의 수정이 있었기 때문에 원본 객체가 손상된 것이다 (원본은 껍데기만 남음)

이때 강력 보장을 지키려면 리소스의 소유권을 다시 원본 객체로 돌려야 하지만 이미 손상된 객체로 이동이 성공한다는 보장이 없다

그렇다면 이동 생성자에 강력한 예외 보장을 어떻게 제공할 수 있을까? 단순히 이동 생성자에서 예외를 던지지 않는것이 간단하지만 이동 생성자에서 예외가 발생할 수 있는 다른 생성자를 호출할 수도 있기 때문에 다른 방법을 생각해야 한다

    class MoveClass
    {
    public:
        MoveClass() = default;
        explicit MoveClass(int resource) : resource{ new int(resource) } {}

        MoveClass(const MoveClass& other)
        {
            if (other.resource)
            {
                resource = new int{ *other.resource };
            }
        }

        MoveClass(MoveClass&& other) noexcept
            : resource{ other.resource }
        {
            other.resource = nullptr;
        }

        ~MoveClass()
        {
            delete resource;
        }

    private:
        int* resource{};
    };

    class CopyClass 
    {
    public:
        bool m_throw{};
        CopyClass() = default;

        CopyClass(const CopyClass& that) : m_throw{ that.m_throw } {
            if (m_throw) {
                throw std::runtime_error{ "abort!" };
            }
        }
    };

    int main()
    {
        std::pair tempPair{ MoveClass{10}, CopyClass{} };

        try 
        {
            tempPair.second.m_throw = true;

            std::pair moved_pair{ std::move(tempPair) };

            std::cout << "moved pair exists\n"; // 절대 출력되지 않음
        }
        catch (const std::exception& ex) {
            std::cerr << "Error found: " << ex.what() << '\n';
        }


        return 0;
    }

std::pair인 tempPair는 MoveClass, CopyClass의 임시객체로 초기화 되었다

try블록에서 일부러 예외를 발생시키기 위해 tempPair.second.m_throw를 true로 변경하고 std::pair타입인 moved_pair로 std::move()를 통해 r-value로 변환 후 이동시켰다

이때 MoveClass에는 이동 생성자가 존재하기 때문에 잘 이동되었지만 CopyClass에는 이동생성자가 없기에 복사 생성자가 호출되었다, 이 과정에서 m_throw가 true라서 예외가 발생했다

try블록에서 예외가 발생했기 때문에 바로 catch로 핸들링되어 Error found가 출력되게 된 것이다

여기서 tempPair를 디버깅 해보면 moved_pair로 tempPair.first는 잘 이동이 되었고 그 후에 tempPair.second를 이동시킬 때 예외가 발생하여 moved_pair의 생성은 실패되었지만 이미 원본 객체인 tempPair는 손상된걸 확인할 수 있다

따라서 위 코드는 강력한 예외 보장이 깨진걸 알 수 있다

위 코드에서 강력한 예외 보장을 지키기 위해서는 std::pair가 move대신 copy를 사용했다면 지켜졌을 것이다, 하지만 모든 객체를 copy한다는건 성능상 매우 불리하기 때문에 좋은 방법은 아니다

이럴때 사용하는것이 바로 std::move_if_noexcept이다

    int main()
    {
        std::pair tempPair{ MoveClass{10}, CopyClass{} };

        try 
        {
            tempPair.second.m_throw = true;

            std::pair moved_pair{ std::move_if_noexcept(tempPair) };

            std::cout << "moved pair exists\n";
        }
        catch (const std::exception& ex) {
            std::cerr << "Error found: " << ex.what() << '\n';
        }


        return 0;
    }

std::move_if_noexcept는 해당 객체가 noexcept이동 생성자를 가지고 있으면 r-value를 반환하여 move를 시킬 수 있게 하고 그렇지 않으면 복사 가능한 l-value를 반환하여 copy할 수 있게 해준다

따라서 위 코드에서 std::pair는 이동 생성자에 noexcept가 없기 때문에 copy를 선택하여 원본이 손상되지 않고 복사가 된 것이다 (강력한 예외 보장)

만약 특정 객체가 noexcept가 아닌 이동 시맨틱이 존재하고 복사 시맨틱이 전부 delete되었다면 std::move_if_noexcept는 위험을 감수하고 move로 처리한다

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

0개의 댓글