예외 처리 코스트가 무거운 이유는 stack unwinding과 같은 작업이 있기 때문이다.
혼자 연구하는 C/C++ 함수와 예외 처리 - 링크 내용 복사입니다.
예외를 던지는 throw
는 보통 try
블록 내부에 있어야 한다. 그러나 함수 안에서는 try
블록없이 throw
만 있을 수도 있다. 이때는 함수를 호출하는 호출원이 try
블록을 가져야 한다. 다음 예제는 0
으로 나누는 함수 divide
를 작성하고 이 함수에서 인수로 전달된 d
가 0
일 때 throw
로 예외를 던진다.
#include <iostream>
void divide(int a, int d){
if (d == 0) throw "0으로는 나눌 수 없습니다.";
std::cout << "나누기 결과 = " << a/d << "입니다." << std::endl;
}
void main(){
try {
divide(10,0); // #1 Stack unwinding
}
catch(const char *message) {
std::cout << message << std::endl;
}
divide(10,5); // #2 Normal
// divide(2,0); // #3 No try catch, process terminated by default
/*
try {
divide(20,0); // #4 Cannot catch, process terminated by default
}
catch(int code) {
printf("%d번 에러가 발생했습니다.\n",code);
}
*/
}
함수 실행중에 throw
를 만나면 대응되는 catch
를 찾기 위해 자신을 호출한 호출원을 거슬러 올라가야 한다. 첫 번째 divide
호출문에서 예외가 발생하면 divide
함수는 자신을 호출한 main
으로 돌아와서 대응되는 catch
문을 찾아 이 코드를 실행한다. catch
는 throw
가 던진 에러 메시지 문자열을 화면으로 그대로 출력할 것이다. 만약 main
과 divide
사이에 다른 함수들이 있더라도 마찬가지로 main
까지 복귀한 후 예외가 처리된다.
함수가 호출될 때는 스택에 각 함수의 스택 프레임이 생성되며 스택 프레임에는 함수 실행에 필요한 여러 가지 정보들이 저장된다. 함수가 리턴할 때 스택 프레임은 정확하게 호출 전의 상태로 돌아가도록 되어 있다. 예외가 발생했을 때 호출원의 catch
로 곧바로 점프해 버리면 스택이 항상성을 잃어 버리므로 이후 프로그램이 제대로 실행될 수 없을 것이다.
그래서
throw
는 호출원으로 돌아가기 전에 자신과 자신을 호출한 함수의 스택을 모두 정리하고 돌아가는데 이를 스택 되감기(Stack Unwinding) 라고 한다.
첫 번째 divide
호출문이 예외를 던질 때 main
의 catch
가 이 예외를 처리한 후 그 다음 문장을 아무 이상없이 실행할 수 있는 이유는 throw
가 스택 되감기를 하여 main
의 스택 프레임을 divide
호출 전의 상태로 복구하기 때문이다.
두 번째 divide(10,5)
는 올바른 인수를 전달했으므로 예외가 발생되지 않으며 호출 후 정상적인 절차대로 리턴한다.
세 번째 divide(2,0)
호출은 두 번째 인수가 0
이므로 예외가 발생하는데 이때 이 예외를 받아줄 catch
문이 없다. 함수 호출부가 try
블록에 있지 않기 때문인데 이때는 예외를 처리할 수 없으므로 디폴트 처리되어 프로그램이 강제로 종료된다.
설사 try
안에 있더라도 예외를 받아줄 catch
가 없으면 이때도 처리되지 않는데 네 번째 호출문 divide(20,0)
의 경우 try
안에 있고 catch
도 있지만 divide
가 던지는 char *
타입의 catch
는 없으므로 역시 처리되지 않고 프로그램은 종료된다.
throw
는 대응되는 try
블록의 catch
를 찾기 위해 스택에서 위쪽 함수를 찾아 올라가면서 호출 스택을 차례대로 정리하는데 이때 각 함수들이 지역적으로 선언한 객체들도 정상적으로 파괴된다. 다음 예제를 통해 스택을 되감는 절차를 연구해 보자.
#include <iostream>
class C {
public:
int a;
C() { std::cout << "생성자 호출" << std::endl; }
~C() { std::cout << "파괴자 호출" << std::endl; }
};
void divide(int a, int d){
if (d == 0) throw "0으로는 나눌 수 없습니다.";
std::cout << "나누기 결과 = " << a/d << "입니다." << std::endl;
}
void calc(int t,const char *m){
C c; // #2
divide(10,0);
// throw 로 인하여 바로 stack unwinding 을 하기에 불리지 않음
std::cout << "This is not called" << std::endl;
}
int main(){
C* test = new C(); // #1
test->a = 10;
try {
test->a=11;
calc(1,"계산");
}
catch(const char *message) {
std::cout << message << std::endl; // #3
}
std::cout << "프로그램이 종료됩니다. -- " << test->a << std::endl;
return 0;
}
/*
생성자 호출 // #1
생성자 호출 // #2
파괴자 호출 // stack unwinding
0으로는 나눌 수 없습니다. // #3
프로그램이 종료됩니다. -- 11 // try 블록 내에서 throw 발생전에 변경된 사항은 반영, 롤백되지 않음
*/
main
의 try
블록에서 calc
를 부르고 calc
는 지역 객체 C
를 선언한다. 그리고 예외를 일으키는 divide(10,0)
을 호출하는데 이 함수에서 throw
에 의해 문자열 예외가 던져진다. 이 때의 스택 상황은 다음과 같을 것이다.
divide
에서 예외가 발생했으므로 이 함수는 더 이상 실행할 수 없다. 그래서 이 예외를 처리할 catch
문을 찾는데 함수 내부에서는 catch
가 없으므로 일단 자신을 호출한 calc
함수로 돌아간다. 이 과정에서 자신의 스택 프레임은 정리하는데 이렇게 하지 않으면 호출원이 예외를 처리하더라도 제대로 실행될 수 없기 때문이다.
calc
에서 다시 catch
를 찾는데 이 함수도 catch
를 가지고 있지 않으므로 같은 방식으로 스택을 정리한다. 이때 calc
의 인수 t
와 m
, 지역변수 C
가 파괴되는데 C
는 객체이므로 정상적인 파괴를 위해 파괴자가 호출된다. calc
가 main
으로 리턴하면 main
의 catch(char *)
로 점프하여 예외를 처리한다. 스택 되감기를 하면서 리턴되는 함수의 모든 지역 객체를 파괴하는데 만약 파괴자를 호출하지 않는다면 예외만 처리될 뿐 생성된 객체들이 제대로 해제되지 않아 프로그램의 상태는 여전히 불안해질 것이다. 파괴자는 단순히 메모리만 정리하는 것이 아니라 때로는 DB 연결 해제, 프로그램 상태 변경 등의 중요한 일을 할 수도 있으므로 반드시 호출해야 한다.
throw
가 대응되는 catch
를 찾기 위해 스택 되감기를 해야 하는 이유는 아주 명백하다. throw
는 catch
로의 점프 동작을 하는데 함수간에 아무렇게나 점프를 해 버리면 스택의 호출 정보는 엉망이 되어 버린다. 호출원으로 돌아갈 때는 스택도 호출원의 것으로 정확하게 복구해야 하며 그러기 위해서는 자신을 호출한 모든 함수의 스택을 일일이 정리해야 하는 것이다. 위 예에서 main
의 마지막에 있는 출력이 제대로 실행되려면 catch
가 예외를 처리한 후 스택의 최상단에는 main의 스택 프레임이 있어야 하며 그러기 위해서는 divide
의 throw
가 divide
와 calc
의 스택을 정리해야 하는 것이다.