본문 바로가기

컴퓨터공학/C, C++

C,C++> 예외 처리

예외

전통적인 예외 처리

예외(Exception)이란 프로그램의 정상적인 실행을 방해하는 조건이나 상태를 의미한다.

잘못 작성해서 오작동하거나 다운되는 에러(Error)와 다르다. 

 

C++는 언어 차원에서 새로운 예외 처리 문법을 제공한다. 

try : 예외가 발생할만한 코드 블록을 지정한다. 이 블록 안에서 예외가 발생하면 throw 명령으로 던진다.  

trow : 프로그램이 정상적으로 실행될 수 없는 상황에 이 명령으로 예외를 던진다. 

catch : try 블록 다음에 이어지며 던져진 예외를 받아 처리한다. catch 블록을 예외 핸들러라고 부른다. catch 다음에 받고자 하는 예외의 타입을 적는다. 이객체는 throw에 의해 던져진다. catch 블록은 예외를 처리하는 코드가 작성된다. 

 

goto나 return으로 catch 안으로 이동할 수 없고 반드시 throw으로만 제어를 옮길 수 있다.

반면 catch문에서 goto, return, break, continue 등 명령으로 블록 밖으로 이동할 수  있다. 

 

catch는 여러 가지 예외 타입에 따라 오버로딩될 수 있다. 

 

함수와 예외 처리

함수 안에 try 블록 없이 throw만 있을 수 있다. 이 때 함수 호출원이 try 블록을 가져야 한다. 

 

함수가 호출될 때 스택에 각 함수 스택 프레임이 생성된다. 

스택 프레임에는 함수 실행에 필요한 여러 가지 정보들이 저장된다. 

함수라 리턴할 때 스택 프레임은 정확하게 호출 전 상태로 돌아가도록 되어 있다. 

예외가 발생했을 때 호출원 catch로 곧바로 점프하면 스택 항상성을 잃어 버리므로 이후 프로그램이 제대로 실행될 수 없다. 그래서 throw는 호출원으로 돌아가기 전 자신과 자신의 호출한 함수의 스택을 모두 정리하고 돌아간다.

이를 스택 되감기(Stack Unwinding)이라고 한다. 

 

첫 호출문이 예외를 던질 때 main의 catch가 이 예외를 처리한 후 그 다음 문장을 아무 이상 없이 실행할 수 있는 이유는 throw가 스택 되감기를 하며 main 스택 프레임을 호출 전 상태로 복구하기 때문이다. 

 

호출부가 try 블록에 있지 않아서 예외를 처리할 catch가 없다면 프로그램이 강제 종료된다. 

try 안에 있더라도 예외를 받을 catch가 없다면 이 때도 처리되지 않는다.

 

throw에 대응되는 try 블록 catch를 찾기 위해 스택에서 위쪽 함수를 찾아 올라가며 호출 스택을 차례대로 정리한다.

이 때 각 함수들이 지역적으로 선언한 객체들도 정상적으로 파괴한다. 

 

throw가 대응되는 catch를 찾기 위해 스택 되감기를 하는 이유는 명백하다. 

throw는 catch로 점프 동작을 하는데 함수 간에 아무렇게 점프하면 스택 호출 정보는 엉망이 된다. 

호출원으로 돌아갈 때 스택도 호출원 것으로 정확학 ㅔ복구해야 하며 그러기 위해 자신을 호출한 모든 함수 스택을 일일이 정리해야 하는 것이다. 

 

중첩 예외 처리 

예외 처리 구문은 중첩 가능하다. try 블록 안에 또 다른 try 블록이 있을 수 있다. 

 

예외 객체

예외를 전달하는 방법

함수가 연산을 하다가 에러가 생기면 어떤 종류 에러가 왜 생겼는지 정보를 전달해야 한다.

전통적인 방법은 에러를 의미하는 정수값을 리턴하는 것이다. 

하지만 이 방법은 에러 코드가 정상 리턴값과 반드시 구분되야 하는 조건이 있다. 

에러 값을 선정하기 힘들고, 별도 변수를 넘겨 에러 여부를 리턴하는 불편한 방법을 사용해야 한다. 

 

 

enum E_Error { OUTOFMEMROY, OVERRANGE, HARDFULL };

void Calc() throw(E_Error)
{
 if(True) throw OVERRANGE;
}

void main()
{
	try {
    	Calc();
    } catch(E_Error e) {
    	...
    	break;
    }
}

위 예시처럼 열거형 에러를 던질 수 있다.

하지만 호출원에서 에러 의미를 일일이 기억하고 해석해야 한다.

따라서 에러가 발생하는 곳에서 에러 의미까지 전달할 수 있게 클래스를 만들어 전달하자. 

클래스에 에러 코드값 멤버, 코드값 생성자, 에러 코드 조사 함수, 에러 메시지 출력 함수를 포함하자.

그리고 에러가 발생했을 때 예외 객체를 생성하여 이 객체를 throw로 던진다. 

catch에서 예외 객체 레퍼런스를 받아 예외 객체에서 에러 코드와 에러 메시지 출력을 예외 객체에게 시킨다. 

이러면 호출원에서 종류에 따라 나눌 필요가 없다. 

 

catch에서 받을 때 레퍼런스가 좋다. 객체는 크기가 커서 전달 속도가 느리다. 포인터를 쓰면 . 연산자 대신 ->를 사용해야 해서 쓰는 쪽에서 불편하고 예외를 던질 때 throw &Exception(1); 처럼 & 연산자를 사용해야 해서 직관성이 떨어진다. 

포인터는 표현식이 복잡하므로 레퍼런스를 쓰는 게 좋다. 

 

예외 클래스 계층

예외 클래스도 클래스라서 상속도 가능하고 다형성도 성립한다. 

예외 클래스 계층을 구성하여 반복 코드를 줄일 수 있고 가상 함수로 예외 처리에 다형성을 적용할 수 있다. 

catch에는 각 예외 객체를 따로 처리할 필요없이 루트 예외 객체에 대해서 처리하면 된다.

왜냐하면 이 클래스로 파생된 클래스는 모드 is a 관계이기 때문이다. 

catch에 전달받은 예외 객체 e로부터 함수를 호출하면 e 타입에 맞게 가상함수를 호출하기 때문이다.

클래스를 이용하면 예외 종류를 판별하지 않아도 된다. 

 

예외와 클래스

클래스 멤버 함수가 예외를 발생할 수 있으면 이 예외에 관한 모든 처리를 클래스 안에 통합하여 넣을 수 있다. 

클래스 안에 예외 클래스를 지역 선언하는 것이다. 

public으로 선언해야 한다.

내부에서만 사용한다면 private도 상관 없겠지만 호출부에서도 예외 객체를 잡을 수 있어야 한다. 그래서 외부에서 참조할 수 있는 public이어야 한다. 

 

생성자와 연산자의 예외

try 블록 안에 객체를 선언하면 블록 지역변수가 되어서 블록 바깥에 존재하지 않는다. 

그래서 try 블록 안에 객체 선언문이 있으면 try 블록은 이 객체를 완전히 사용하는 코드를 전부 포괄해야 한다. 

 

try 블록 함수 

try 블록 자체를 함수 본체로 만드는 것이 가능하다. 함수 시작과 끝을 표시하는 { } 괄호를 없애 버리고 try와 catch를 함수 본체인 것처럼 만들면 된다. 

public:
	Position(int ax, int ay, char ach)
    try : x(ax), y(ay) {
    	if (ax < 0) throw ax;
        ch=ach;
    }
    catch (int a){
    
    }

이런 표시가 필요한 이유는 초기화 리스트 실행 중 발생할 수 있는 예외까지 처리할 필요가 있기 때문이다.

기존 방식 일부 코드를 감싸는 try 블록 표기법은 본체 코드 전체를 감쌀 수 있어도 초기화 리스트까지 예외 처리 블록에 포함할 수 없다. 

 

생성자에서 객체 생성 조건이 맞지 않을 경우 예외를 처리하더라도 이 예외는 자동으로 다시 던져진다. 

이 객체를 선언하는 곳에도 예외 사실을 알려야 한다. 그래서 main에서 객체 선언 문장을 다시 try로 감싸야 한다. 

객체 혼자만의 문제가 아니라 이 객체를 선언하는 곳과도 관련이 있다.

 

예외 지정

미처리 예외

미처리 예외 핸들러를 따로 등록할 수 있다. 

catch(...)를 사용하는데 이때 ...은 앞부분 catch에서 처리되지 않은 모든 예외를 의미한다. 

catch(...)은 예외가 발생했다는 것만 알 수 있으며 어떤 예외가 왜 발생했는지 알지 못하는 한계가 있다.

이 구문은 잘 사용되지 않는다. 

terminate는 전역 미처리 핸들러이고 catch(...)은 국지적인 미처리 예외 핸들러이다. 

 

throw로 예외가 생길 때 컴파일러는 try 블록 밑 catch를 순서대로 점검한다. 

만약 catch(...)가 맨 앞에 있따면 뒤쪽 catch는 호출되지 않는다. 

그래서 catch(...)는 맨 끝에 와야 한다.

 

대입이나 함수 호출은 컴파일러가 적당한 타입으로 변환하지만 예외 객체는 정확한 타입만 찾는다. 

심지어 int와 short 처럼 길이만 다른 타입오 다른 예외 객체로 인식한다. 

예외적으로 void * 타입을 받는 핸들러는 임의 포인터 타입 객체를 받을 수 있다.

부모 포인터 타입을 받는 핸들러는 자식 객체를 받을 수 있다.

 

예외 지정

함수 작성 시 함수 원형 뒤에 예외 종류를 지정할 수 있다. 예외 종류가 두 가지 이상이면 괄호 안에 예외 타입들을 콤마로 구분해서 나열한다. 

예외를 던지지 않는 함수는 throw()만 적고 괄호 안을 비운다. 

함수 원형 뒤에 아무것도 적지 않으면 임의의 예외를 던질 수 있다는 뜻이다.

그래서 두 함수의 뜻은 다르다.

void func(int a, int d) throw()
void func(int a, int d)

전자는 예외를 던지지 않으며 후자는 임의의 예외를 던질 수 있고 아닐 수도 있다. 

함수 원형에 던질 수 있는 예외 종류를 지정하는건 문서화의 의미가 있다. 

이 함수를 사용하는 사람에게 어떤 종류 예외가 발생할 수 있는지를 알려 준다.

개발자는 원형 뒤 쪽 타입에 적절한 catch문을 작성할 수 있다.

 

예외 지정은 함수 실행 중 발생할 수 있는 모든 예외 정보를 제공해야 하므로 함수 자신이 던지는 예외뿐만 아니라 함수가 호출하는 함수에서 발생하는 예외까지 지정해야 한다. 그러나 지정된 예외가 아닌 예외를 던지는 함수라도 이 호출이 금지되지 않는다. 

 

void fA() throw(int, double){

}

void fB() throw(char){
	fA();
}

fA를 호출하는 fB도 char 뿐만 아니라 int, double 를 명시해야 하는 게 원칙이다. 

자신이 호출하는 함수 예외를 직접 처리하지 않는다면 예외는 계속 호출 스택 아래쪽으로 다시 던져진다. 

fB가 fA를 호출하는 걸 금지하는 건 이치에 맞지 않다. 

자신이 호출하는 함수가 내부적으로 호출하는 함수 목록을 정확히 파악하는 건 어렵다. 

지정하지 않은 예외가 발생했을 경우 unexpected 라는 함수가 호출된다. 

이 함수가 미지정 예외를 처리한다 .

unexpected는 디폴트로 terminate를 호출하여 프로그램을 강제 종료한다. 

 

unexpected_handler set_unexpected(unexpected_handler ph)

 

예외의 비용

C++의 예외 처리 기능을 사용하면 프로그램 성능이 느려진다. 

안정성과 유지 보수 편의성은 증가하지만 프로그램이 비대해지고 느려진다. 

특히 스택 되감기 기능은 호출한 모든 스택의 정리하는 대공사를 한다. 

예외가 발생하지 않는다면 성능 저하는 거의 없다. 

그러나 try, catch 키워드를 쓰면 프로그램 용량이 비대해지믄 문제가 있다.

왜냐하면 예외가 발생했을 때 코드를 작성해야 하기 때문이다. 

성능이 중요하다면 예외 처리 기능을 사용하지 말아야 한다. 

오히려 전통적인 if문을 사용하는 게 바람직하다. 

 

메모리 할당 원칙에 의하면 일단 할당한 메모리는 명식적으로 해제하지 않는 한 임의로 회수할 수 없다. 

throw는 남은 뒷부분 코드를 무시하고 무조건 예외 핸들러로 점프해버려서 만약 throw 뒤에 메모리 해제 함수가 있다면 할당 해제하지 못한다. 

이 문제를 해결하기 위해 포인터처럼 동작하며 스스로 할당된 메모리를 해제하는 스마트 포인터를 사용할 수 있다. 

 

C++ 예외 처리 구문은 클래스 템플릿에서 쓸 수 없다. 

왜냐하면 전달 인수 타입에 따라 발생할 수 있는 예외는 너무 다양해서 언제 어떤 예외가 발생할 건지 예측할 수 없기 때문이다. 

또한 예외 처리 구문은 멀티 스레드 환경에서 여러 가지 문제가 있다. 동기화 문제를 더 복잡하게 만든다. C++ 예외 처리 기능은 멀티 스레드를 고려하여 동기적으로 설계되어 있지만 실제 적용 시 여러 가지복잡한 규칙을 따라야 하고 주의 사항도 많아서 예외 처리가 무척 어렵다. 

즉, 예외 처리는 템플릿과 멀티 스레드와 궁합이 맞지 않다. 

 

예외 처리 기능은 예외가 발생했을 때 적당한 핸들러를 찾아 점프하는 기능이다. 제어를 옮길 뿐이고 예외를 복구하지 못한다. catch에서 조치를 취하고 try 블록 안으로 다시 리턴할 수 없다.

 

 

'컴퓨터공학 > C, C++' 카테고리의 다른 글

C,C++> 네임 스페이스  (2) 2022.09.02
C,C++> 타입 정보  (0) 2022.09.02
C,C++> 템플릿  (0) 2022.08.31
C,C++> 다형성  (0) 2022.08.22
C,C++> 상속  (0) 2022.08.19