책정리/혼자 연구하는 C,C++ 2

32장 예외 처리

GONII 2015. 3. 7. 18:59

32-1 예외

가. 전통적인 예외 처리

예외(Exception)란 프로그램의 정상적인 실행을 방해하는 조건이나 상태를 의미하는데 프로그램을 잘못 작성해서 오작동하거나 다운되게 만드는 에러(Error)와는 다르다. 원칙적으로 에러는 개발 중에 모두 수정해야 하는데 모르고 그냥 남겨두지 않을 것이다.

최종 릴리즈할 때까지 미처 발견하지 못하면 이것을 버그라고 부르며 그 중에서도 아주 악질적인 에러를 버그라고 부른다. 예외란 버그와는 달리 제대로 만들었지만 원하는 대로 동작하지 못하게 방해하는 외부의 불가항력적인 상황을 말한다.

프로그램을 아무리 치밀하게 논리적으로 잘 작성하더라도 예외는 항상 발생할 수 있는데 왜냐하면 작성 시점에서 실행 시의 모든 상황을 정확하게 예측 할 수 없기 때문이다. 항상 정해진 절차대로 프로그램을 동작시키고 정확한 값만 입력한다면 문제가 없겠지만 실수투성이인 사람은 그렇지 못하다.

프로그램이 실행되는 환경 또한 불확실하기는 마찬가지이다. 하드 디스크가 언제 가득찰지 예측할 수 없으며 프린터의 종이가 언제 떨어질 지도 알 수 없다. 또한 컴퓨터 외부의 환경인 네트워크도 불안정해서 언제든지 끊어질 수 있고 알 수 없는 이유로 데이터가 중간에서 사라지기도 한다. 잘 짜여진 프로그램은 이런 여러가지 예외 상황에도 잘 대처해야 하는데 잘못된 입력이 왜 잘못되었는지 사용자에게 알리고 다시 입력하도록 해야 하며 실패한 동작은 재시도해야 한다. 어떤 경우라도 최소한 프로그램이 다운되지는 않도록 해야 한다. 예외를 잘 처리하지 못하면 이것도 일종의 버그가 된다.

프로그램은 사용자와 상호 작용하거나 외부의 환경과 통신할 때 항상 방어적인 코드로 발생 가능한 모든 예외를 적절하게 처리해야 한다.

프로그램은 사용자와 상호 작용하거나 외부의 환경과 통신할 때 항상 방어적인 코드로 발생 가능한 모든 예외를 적절하게 처리해야 한다.

  • 예제 TraditionalError

#include <iostream>

   

void main()

{

int a,b;

   

printf("나누어질 수를 입력하시오 : ");

scanf("%d", &a);

if( a < 0 )

{

printf("%d는 음수이므로 나누기 거부\n", a);

}

else

{

printf("나누는 수를 입력하시오 : ");

scanf("%d", &b);

if( b == 0 )

{

puts("0으로 나눌 수 없습니다.");

}

else if( b < 0 )

{

printf("%d는 음수이므로 나누기 거부\n", b);

}

else

{

printf("나누기 결과 = %d\n", a/b);

}

}

}

if문으로 조건을 점검하여 예외를 일으킬만한 상황을 피해가는 이런 전통적인 방법은 지금까지 많이 사용해왔고 지극히 상식적인 방법이다. 하지만 점검할 예외가 많아지면 여러 가지 코드의 품질이 떨어진다.

에러를 점검하는 if문과 에러 메시지 출력문이 너무 떨어져서 대응되는 코드를 한눈에 알아보기도 어렵다. 게다가 잦은 if문으로 인해 들여쓰기가 지나치게 깊어져 코드의 가독성이 떨어진다.

이런 코드를 분석할 땐느 잘 방생하지 않는 예외 코드는 일단 무시하고 읽어야 하는데 무질서하게 섞여 있다 보니 어디가 예외 처리 코드인지 어디가 진짜 코드인지 잘 분간되지도 않는다. 안정적인 프로그램을 만들기 위해서는 발생 가능한 모든 예외를 처리할 필요가 분명히 있다. 그러나 전통적인 방법은 여러 가지로 좋지 않은 효과가 있어 질적으로 다른 방법이 필요해졌다.

나. C++의 예외 처리

C++은 함수나 컴파일러, 라이브러리 수준이 아닌 언어 차원에서 새로운 예외 처리 문법을 제공한다.

  • try : 예외가 발생할만한 코드 블록을 지정하는데 try 다음의 { } 괄호 안에 예외 처리 대상 코드를 작성한다. 이 블록 안에서 예외가 발생했을 때 throw명령으로 예외를 던진다.
  • throw : 프로그램이 정상적으로 실행될 수 없는 상황일 때 이 명령으로 예외를 던진다. throw 다음에 던지고자 하는 예외를 적는다. 예외를 던진다는 것ㄱ은 예외가 발생되었다는 것을 알리며 이 예외를 처리하는 catch문으로 점프하도록 한다. throw 명령 아래쪽의 코드들은 모두 무시되며 곧바로 예외 처리 구문으로 이동한다.
  • catch : try 블록 다음에 이어지며 던져진 예외를 받아서 처리한다. 그래서 catch 블록을 예외 핸들러라고 부른다. catch 다음에는 받고자 하는 예외의 타입을 적는데 이 객체는 throw에 의해 던져진다. catch 블록에는 예외를 처리하는 코드가 작성된다.

try {

if( 예외 조건 ) throw 예외 객체;

}

catch ( 예왜 객체 ) {

예외 처리

}

  • 예제 ifexcept

#include <iostream>

#include <windows.h>

#include <math.h>

   

void gotoxy(int x, int y)

{

COORD pos = {x, y};

SetConsoleCursorPosition(GetStdHandle(STD_OUTPUT_HANDLE), pos);

}

   

void main()

{

int x, y, r;

   

printf("x 좌표 입력 : "); scanf("%d", &x);

if( x < 0 )

{

printf("%d는 음수이므로 잘못된 값입니다.\n", x);

exit(-1);

}

printf("y좌표 입력 : "); scanf("%d", &y);

if( y < 0 )

{

printf("%d는 음수이므로 잘못된 값입니다.\n", y);

exit(-1);

}

printf("숫자 입력 : "); scanf("%d", &r);

if( r < 0 )

{

printf("%d는 음수이므로 잘못된 값입니다.\n", r);

exit(-1);

}

   

gotoxy(x,y);

printf("%d의 제곱근은 %.4f입니다.\n",r, sqrt((float)r));

}

이 예제를 C++의 예외 처리 구문으로 바꾸면 다음과 같이 정리할 수 있다.

  • 예제 trycatch

#include <iostream>

#include <windows.h>

#include <math.h>

   

void gotoxy(int x, int y)

{

COORD pos = {x, y};

SetConsoleCursorPosition(GetStdHandle(STD_OUTPUT_HANDLE), pos);

}

   

void main()

{

int x, y, r;

   

try

{

printf("x 좌표 입력 : "); scanf("%d", &x);

if( x < 0 ) throw x;

printf("y 좌표 입력 : "); scanf("%d", &y);

if( y < 0 ) throw y;

printf("숫자 입력 : "); scanf("%d", &r);

if( r < 0 ) throw r;

}

catch(int a)

{

printf("%d는 음수이므로 잘못된 값\n", a);

exit(-1);

}

   

gotoxy(x,y);

printf("%d의 제곱근은 %.4f입니다.\n",r, sqrt((float)r));

}

예외가 발생할 가능성이 있는 입력문들을 모두 try로 둘러싸고 try 블록 안에서 잘못된 값이 입력될 때마다 throw로 입력된 정수값을 던지기만 했다. throw가 에러를 유발시킨 정수를 던지면 try 블록 다음의 catch에서 이 정수를 받아 에러 메시지를 출력하고 exit(-1)로(또는 return) 프로그램을 종료한다. 이 때 try 블록의 throw 아래에 있는 코드는 무시되는데 한 값이 잘못 입력되었으면 다음 값은 입력받을 필요가 없기 때문이다.

만약 예외가 발생하지 않으면 catch 블록에 있는 예외 처리 코드는 실행되지 않고 무시된다. 똑같이 반복되는 에러 처리 코드를 한 곳에 모을 수 있어 코드가 짧아지며 에러 처리 구문과 고유의 처리 코드가 분리되어서 읽기에도 좋다.

catch 블록 안의 코드는 예외가 발생할 때만 실행되며 오로지 throw에 의해서만 이동 가능하다. throw로 던질 때만 예외가 발생하며 cath 블록은 예외가 발생할 때만 호출된다. catch문 안에서는 goto, return, break, continue 등의 명령들로 블록 밖으로 이동할 수 없다.

catch가 처리하는 예외는 극단적인 상황에 대한 대책이므로 이런 코드를 분석할 때는 무시하고 읽어도 상관없다.

하나의 try블록에서 타입이 다른 여러 개의 예외를 발생시킬 수도 있는데 이때는 예외의 타입수 만큼 catch를 try 블록 다음에 나열하면 된다. 각 catch문들은 모두 try와 한 덩어리이므로 catch문 사이에도 다른 문장이 끼어들어서는 안된다.

  • 예제 multicatch

#include <iostream>

   

void main()

{

int a, b;

   

try

{

printf("나누어질 수를 입력 : ");

scanf("%d", &a);

if( a < 0 ) throw a;

printf("나누는 수를 입력 : ");

scanf("%d", &b);

if( b == 0 ) throw "0으로 나눌 수 없습니다.";

printf("나누기 결과는 %d\n", a/b);

}

catch(int a)

{

printf("%d는 음수\n", a);

}

catch(const char* message)

{

puts(message);

}

}

catch는 마치 throw에 의해 호출되는 함수에 비유될 수 있으며 함수가 오버로딩될 수 있듯이 catch도 여러 가지 예외 타입에 따라 오버로딩 될 수 있다. throw가 던지는 예외 타입과 일치하는 catch가 호출되는 것이다. 필요하다면 catch 내에서 지역 변수를 선언해서 사용할 수도 있다. 물론 catch가 진짜 함수는 아니다. catch로 이동하면 다시 리턴하지 않으므로 throw에 의해 점프되는 레이블이라고 보는 편이 더 타당하다ㅏ. throw는 호출(call)이 아니라 무조건 분기문인 goto와 더 가깝다.

다. 함수와 예외 처리

예외를 던지는 throw는 보통 try 블록 내부에 있어야 한다. 그러나 함수 안에서는 try 블록 없이 throw만 있을 수도 있다. 이 때는 함수를 호출하는 호출원이 try블록을 가져야 한다.

  • 예제 throwfunc

#include <iostream>

   

void divide(int a, int d)

{

if( d == 0 ) throw "0으로 나눌 수 없습니다.";

printf("나누기 결과 = %d\n", a/d);

}

   

void main()

{

try

{

divide(10, 0);

}

catch(const char *message)

{

puts(message);

}

divide(10,5);

//divide(2,0);

   

/*try

{

divide(20,0);

}

catch(int code)

{

printf("%d번 에러 발생\n", code);

}*/

}

함수 실행 중에 throw를 만나면 대응되는 catch를 찾기 위해 자신을 호출한 호출원을 거슬러 올라가야 한다. 첫 번째 divide 호출문에서 예외가 발생하면 divide 함수는 자신을 호출한 main으로 돌아와서 대응된느 catch문을 찾아 이 코드를 실행한다.

함수가 호출될 땐느 스택에 각 함수의 스택 프레임이 생성되며 스택 프레임에는 함수 실행에 필요한 여러 가지 정보들이 저장된다. 함수가 리턴할 때 스택 프레임은 정확하게 호출 전의 상태로 돌아가도록 되어 있다. 예외가 발생했을 때 호출원의 catch로 곧바로 점프해 버리면 스택이 항상성을 잃어버리므로 이후 프로그램이 제대로 실행될 수 없을 것이다. 그래서 throw는 호출원으로 돌아가기 전에 자신과 자신을 호출한 함수의 스택을 모두 정리하고 돌아가는데 이를 스택 되감기(Stack Unwinding)라고 한다.

세번째 divide(2,0) 호출은 두 번째 인수가 0이므로 예외가 발생하는데 이 때 이 예외를 받아줄 catch문이 없다. 함수 호출부가 try 블록에 있지 않기 때문인데 이때는 예외를 처리할 수 없으므로 프로그램이 종료된다.

네번째 divide(20,0)의 경우 try안에 있고 catch도 있지만 divide가 던지는 const char* 타입의 catch가 없으므로 역시 처리되지 않고 프로그램이 종료된다.

throw는 대응되는 try 블록의 catch를 찾기 위해 스택에서 위쪽 함수를 찾아 올라가면서 호출 스택을 차례대로 정리하는데 이 때 각 함수들이 지역적으로 선언한 객체들도 정상적으로 파괴된다.

  • 예제 stackunwinding

#include <iostream>

   

class C

{

int a;

public :

C() { puts("C생성자 호출"); }

~C() { puts("~C 소멸자 호출"); }

};

   

void divide( int a, int d )

{

if( d == 0 ) throw "0으로 나눌수없음";

printf("나누기 결과 = %d\n", a/d);

}

   

void calc( int t, const char *m)

{

puts("c객체 생성");

C c;

divide(10, 0);

}

   

void main()

{

try

{

calc(1, "계산");

}

catch(const char *message)

{

puts(message);

}

puts("프로그램 종료");

}

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*)로 점프하여 예외를 처리한다. 스택 되감기를 하면서 리턴되는 함수의 모든 지역 객체를 파괴하는데 만약 소멸자를 호출하지 않는다면 예외만 처리될 뿐 생성된 객체들이 제대로 해제되지 않아 프로그램의 상태는 여전히 불안해질 것이다.

스택 되감기를 해야 하는 이유는 아주 명백하다. 호출원으로 돌아갈 때는 스택도 호출원의 것으로 정확하게 복구해야 하며 그러기 위해서는 자신을 호출한 모든 함수의 스택을 일일이 정리해야 하는 것이다.

라. 중첩 예외 처리

예외 처리 구문은 중첩 가능하다. 즉 try 블록 안에 또 다른 try 블록이 있을 수 있으며 중첩 단계에는 별다른 제약이 없다.

  • 예제 nesttry

#include <iostream>

   

void main()

{

int num;

int age;

char name[128];

   

try

{

printf("학번을 입력하세요 : ");

scanf("%d", &num);

fflush(stdin);

if( num <= 0 ) throw num;

try

{

printf("이름을 입력하세요 : ");

gets(name);

if( strlen(name) < 4 ) throw "이름이 짧음";

printf("나이를 입력하시요 : ");

scanf("%d", &age);

if( age <= 0 ) throw age;

printf("입력한 정보 => 학번:%d, 이름:%s, 나이:%d\n", num, name, age);

}

catch(const char*message)

{

puts(message);

}

catch(int)

{

throw;

}

}

catch(int n)

{

printf("%d는 음수\n", n);

}

}

catch에서 바깥쪽 catch로 점플할 때는 throw 명령만 단독으로 사용하는데 만약 바깥쪽에 적절한 catch가 없다면 이 예외는 디폴트 처리되어 프로그램이 강제 종료된다. 예외를 던지는 함수끼리 서로 호출하다 보면 예외 처리 블록을 중첩해야 하는 경우가 있다.

32-2 예외 객체

가. 예외를 전달하는 방법

함수가 어떤 연산을 하던 중에 프로그램을 정상적으로 실행할 수 없는 에러가 발생했을 때 함수는 에러가 발생했다는 사실 뿐만 아니라 어떤 종류의 에러가 왜 발생했는지 상세한 정보를 전달해야 한다. 그래야 호출원에서 에러의 종류에 따라 다음 동작을 경정할 수 있을 것이다. 전통적인 방법은 에러를 의미하는 정수값을 리턴하는 것이다.

  • 예제 ExceptionReturn

#include <iostream>

   

int calc()

{

// 메모리 할당 후 연산해서 파일로 출력하는 동작을 한다고 하고

if( true/*예외 발생*/) return 1;

// 여기까지 왔으면 작업 완료

return 0;

}

   

void main()

{

int e;

e = calc();

switch(e)

{

case 1:

puts("메모리 부족");

break;

case 2:

puts("연산 범위 초과");

break;

case 3:

puts("하드 디스크 용량 부족");

break;

default:

puts("작업 완료");

break;

}

}

  • 예제 ExceptionEnum

#include <iostream>

   

enum E_Error { OUTOFMEMORY, OVERRANGE, HARDFULL };

   

int calc() throw(E_Error)

{

// 메모리 할당 후 연산해서 파일로 출력하는 동작을 한다고 하고

if( true/*예외 발생*/) throw OVERRANGE;

// 여기까지 왔으면 작업 완료

return 0;

}

   

void main()

{

int e;

try

{

e = calc();

puts("작업완료");

}

   

catch(E_Error e)

{

switch(e)

{

case OUTOFMEMORY:

puts("메모리 부족");

break;

case OVERRANGE:

puts("연산 범위 초과");

break;

case HARDFULL:

puts("하드 디스크 용량 부족");

break;

default:

puts("작업 완료");

break;

}

}

}

열거형의 에러 값은 정수형보다 의미가 좀 더 분명하다는 면에서 사용하기 쉽다. 그러나 호출원에서 에러의 의미를 일일이 기억하고 해석해야 한다는 점에 있어서 여전히 불편하다. throw로 던질 수 있는 예외 객체의 타입에는 제한이 없으므로 문자열을 포함하는 구조체를 던진다면 에러 메시지를 구조체에 포함시킬 수 있을 것이다.

  • 예제 ExceptionObject

#include <iostream>

   

class Exception

{

private:

int ErrorCode;

public:

Exception(int ae) : ErrorCode(ae) {}

int GetErrorCode() { return ErrorCode ; }

void ReportError()

{

switch( ErrorCode )

{

case 1:

puts("메모리 부족");

break;

case 2:

puts("연산 범위 초과");

break;

case 3:

puts("하드 디스크 용량 부족");

break;

}

}

};

   

void calc()

{

// 메모리 할당 후 연산해서 파일로 출력하는 동작을 한다고 하고

if( true/*예외 발생*/) throw Exception(1);

// 여기까지 왔으면 작업 완료

}

   

void main()

{

int e;

try

{

calc();

puts("작업완료");

}

   

catch(Exception &e)

{

printf("에러 코드 = %d =>", e.GetErrorCode());

e.ReportError();

}

}

Exception이라는 예외 클래스르 먼저 정의하고 이 클래스 안에는 에러 코드값을 가지는 멤버와 생성자, 에러 코드를 조사하는 함수, 에러 메시지를 출력하는 함수가 포함되어 있다. 에러에 대한 모든 처리를 클래스 하나에 작성해 놓는 것이다.

catch에서 이 객체를 잡을 때는 가급적 레퍼런스로 잡는 것이 좋다. 물론 레퍼런스가 아닌 객체 자체를 값으로 받거나 포인터로 받아도 잘 동작한다. 그러나 알다시키 객체는 크기 때문에 값으로 받으면 전달 속도가 느리다는 단점이 있다. 포인터를 쓰면 . 연산자 대신 ->를 사용해야 하므로 쓰는 쪽에서 불편할 뿐만 아니라 예외를 던질 때도 throw &Exception(1); 과 같이 &연산자를 사용해야 하므로 직관적이지 못하다.

나. 예외 클래스 계층

예외 클래스도 클래스이므로 상속할 수 있고 다형성도 성립한다. 비슷한 종류의 예외라면 예외 클래스의 계층을 구성하여 반복되는 코드를 줄일 수 있고 가상 함수에 의해 예외 처리에도 다형성을 적용할 수 있다.

  • 예제 InheritException

#include <iostream>

   

class ExNegative

{

protected:

int number;

public:

ExNegative(int n) : number(n) {}

virtual void printError()

{

printf("%d는 음수\n", number);

}

};

   

class ExTooBig : public ExNegative

{

public:

ExTooBig(int n) : ExNegative(n) {}

virtual void printError()

{

printf("%d는 너무 큽니다. 100보다 작아야됨\n", number);

}

};

   

class ExOdd : public ExTooBig

{

public:

ExOdd(int n) : ExTooBig(n) {}

virtual void printError()

{

printf("%d는 홀수입니다. 짝수여야함\n", number);

}

};

   

void main()

{

int n;

   

for (;;)

{

try

{

printf("숫자를 입력하세요(끝낼 때 0) : ");

scanf("%d", &n);

if( n == 0 ) break;

if ( n < 0 ) throw ExNegative(n);

if( n > 100 ) throw ExTooBig(n);

if( n % 2 != 0 ) throw ExOdd(n);

   

printf("%d 숫자는 규칙에 맞음\n", n);

}

catch(ExNegative &e)

{

e.printError();

}

}

}

루트 예외 클래스인 ExNegative가 printError를 가상 함수로 정의했으므로 파생 클래스의 printError도 모두 동적으로 결합되는 가상 함수이다.

main에서 비슷한 예외들을 처리할 때는 에러 내용에 맞는 예외 객체를 생성하여 던지기만 하면 된다. catch는 각 예외 객체를 따로 처리할 필요없이 루트 예외 객체인 ExNegative에 대해서만 처리하면 된느데 왜냐하면 이 클래스로부터 파생된 클래스들은 모두 ExNegative와 IS A 관계에 있기 때문이다. catch에는 전달받은 예외 객체 e로부터 printError 함수만 호출하면 e의 타입에 마는 가상 함술르 호출 할 수 있어 예외의 종류를 판별하는 일은 신경쓰지 않아도 된다. e.printError가 다형적으로 에러를 처리한다.

다. 예외와 클래스

클래스의 멤버 함수가 특정한 종류의 예외를 발생시킬 수 있다면 이 예외에 대한 모든 처리를 클래스 안에 완벽하게 통합해 넣을 수 있다. 클래스 내부에 예외 클래스를 지역적으로 선언하면 이 클래스는 스스로 예외를 처리할 수 있으며 예외 처리 코드까지 포함하고 있으므로 어떤 상황에서도 예외를 처리할 수 있게 된다.

  • 예제 ExceptionClass

#include <iostream>

   

class myClass

{

public:

class Exception

{

private:

int ErrorCode;

public:

Exception(int ae) : ErrorCode(ae) {}

int GetErrorCode() { return ErrorCode; }

void ReportError()

{

switch(ErrorCode)

{

case 1:

puts("메모리 부족");

break;

case 2:

puts("연산 범위 초과");

break;

case 3:

puts("하드 디스크 용량 부족");

break;

}

}

};

void calc()

{

try

{

if( true/*에러발생*/) throw Exception(1);

}

catch(Exception &e)

{

printf("에러 코드 = %d => ", e.GetErrorCode() );

e.ReportError();

}

}

void calc2() throw(Exception)

{

if(true/*에러발생*/) throw Exception(2);

}

};

   

void main()

{

myClass m;

m.calc();

try

{

m.calc2();

}

catch(myClass::Exception &e)

{

printf("에러 코드 = %d => ", e.GetErrorCode() );

e.ReportError();

}

}

예외를 처리하는데 클래스 계층을 구성하고 가상 함수를 이용한 다형성까지 활용하고 있으며 통합성을 높이기 위해 잘 사용하지 않는 지역 클래스까지 선언한다. 여기에 추상 클래스와 순수 가상 함수까지 동원하면 훨씬 더 복잡해질 수도 있다. 잘 발생하지도 않는 예외 처리를 위해 이런 문법까지 동원하는 것은 왠지 격이 어울리지 않는 것 같아 보이기도 한다.

예외 발생시 어떻게 대처할 것인가는 응용 프로그램에 따라 달라지는데 가벼운 예외라면 무시하고 지나갈 수도 있고 사용자에게 알릴 수도 있고 실행을 계속 할 수 없을 정도로 치명적이라면 적극적으로 해결해야 하는 경우도 있다. 라이브러리는 예외 발생 사실과 원인 등 상세한 정보를 호출측에 전달하기만 하면 된다.

라. 생성자와 연산자의 예외

예외 구문은 리턴값에 의존하지 않고 특정 조건이 되었을 때 원하는 곳으로 제어를 옮길 수 있으므로 생성자와 연산자의 에러 처리에도 사용할 수 있다.

  • 에제 CtorException

#include <iostream>

   

class int100

{

private:

int num;

public:

int100(int a)

{

if( a <= 100 )

{

num = a;

}

else

{

throw a;

}

}

int100 &operator +=(int b)

{

if( num + b <= 100 )

{

num += b;

}

else

{

throw num + b;

}

return *this;

}

void outValue()

{

printf("%d\n", num);

}

};

   

void main()

{

try

{

int100 i(85);

i += 12;

i.outValue();

}

catch(int n)

{

printf("%d는 100보다 큰 정수\n", n);

}

}

예외 처리 구문없이 생성자의 에러를 처리하려면 디폴트 생성자를 따로 두고 생성을 대신하는 Init따위의 함수에서 에러를 처리할 수도 있을 것이다. 이 경우 사용자는 객체 생성 후 반드시 Init를 호출해야 하는 부담이 있다. 또는 호출원에서 객체를 생성하기 전에 전달할 인수값을 점검하는 방법을 생각해 볼 수도 있을 것 같지만 이런 방법은 객체를 동적으로 생성할 때만 사용할 수 있어 일반성이 없다.

+= 연산자는 값을 증가시키는데 초기화할 때는 100 이하였더라도 증가 연산에 의해 100보다 큰 값이 될 수 있으므로 역시 예외를 던진다. += 연산자는 연쇄적인 연산을 위해 클래스형의 레퍼런스를 리턴하므로 에러를 의미하는 특이한 값을 리턴하는 것도 불가능하다.

생성자는 예외 처리 구문을 쓰는 대신 성공적인 생성 여부를 표시하는 별도의 멤버를 두고 객체 생성 후에 이 멤버의 값을 평가하는 방법을 더 많이 사용한다. 생성자는 객체 생성에 실패할 경우 성공 여부 플래그에 에러 코드를 대입해 놓고 객체를 쓰는 쪽에서 이 플래그를 점검한다.

마. try 블록 함수

어떤 함수의 본체 어느 곳에서나 예외가 발생할 수 있다면 이 함수의 본체를 try 블록으로 완전히 묶어야 한다.

  • 예제 tryfunc

#include <iostream>

   

void divide( int a, int d )

{

try

{

if( d == 0 ) throw "0으로 나눌 수 없습니다.";

printf("나누기 결과 = %d\n", a/d);

}

catch(const char* message)

{

puts(message);

}

}

   

void main()

{

divide(10,0);

}

  • 예제 tryctor

#include <iostream>

   

class position

{

private:

int x, y;

char ch;

public:

position( int ax, int ay, char ach)

try : x(ax), y(ay)

{

if( ax < 0 ) throw ax;

ch = ach;

}

catch(int a)

{

printf("%d는 음수 좌표\n",a);

}

   

void outPosition()

{

printf("x : %d, y : %d, ch : %c", x, y, ch);

}

};

   

void main()

{

try

{

position here(-1, 10, 'x');

here.outPosition();

}

catch(int)

{

puts("무효한 객체임");

}

}

생성자 본체가 시작되자마자 try가 먼저 나오고 try와 시작 괄호 사이에 초기화 리스트가 배치된다. 이 표기법이 꼭 필요한 이유는 초기화 리스트 실행 중에 발생할 수 있는 예외까지도 처리할 필요가 있기 때문이다.

생성자에서 객체 생성 조건이 맞지 않을 경우의 예외를 처리하더라도 이 예외는 자동으로 다시 던져지도록 되어 있다. 왜냐하면 객체 생성 단계의 예외는 객체 혼자만의 문제가 아니라 이 객체를 선언한 곳과도 관련이 있으므로 객체를 쓰는 주체에게도 예외 사실을 반드시 알려야 하기 때문이다. 그래서 main에서 here 객체를 선언한 문장을 다시 try로 감싸고 있다 만약 이 처리를 생략하면 생성자에서 발생한 예외는 미처리 예외가 되어 프로그램이 다운된다.

바. 표준 예외

표준 C++ 라이브러리는 모든 예외의 루트로 사용할 수 있는 exception이라는 클래스를 정의한다. 이 클래스는 별다른 기능을 가지지 않으며 문자열 포인터를 리턴하는 what이라는 가상 함수를 제공한다. exception의 what은 별다른 출력이 없지만 파생 클래스는 원하는 문자열을 출력하도록 재정의할 수 있다. 표준 C++ 라이브러리는 exception으로부터 표준 예외 클래스들을 파생해 놓았다. 표준 예외는 크게 논리 에러와 런타임 에러로 나누어지며 exception에서 직접 파생되는 것들도 있다.

  • 예제 bad_alloc

#include <iostream>

#include <new>

#include <windows.h>

   

void main()

{

int *pi[1000] = {NULL,};

int i;

   

try

{

for ( i = 0 ; ; i++ )

{

pi[i] = new int[100000000];

if( pi[i] )

{

printf("%d번째 할당 성공\n",i);

}

else

{

printf("%d번째 할당 실패\n", i);

}

Sleep(100);

}

}

catch(std::bad_alloc &b)

{

puts("에러 발생");

std::cout << b.what() << std::endl;

}

for( i = 0 ;; i++ )

{

delete[] pi[i];

}

}

32-3 예외 지정

가. 미처리 예외

throw가 예외를 던졌는데 이 예외를 받아줄 catch가 없는 경우는 아무도 이 예외를 처리하지 않는다. 이 예외는 미처리 예외가 된다. 미처리 예외는 terminate라는 함수가 처리하는데 이 함수는 기본적으로 abort를 호출하여 프로그램을 강제로 종료한다.

만약 미처리 예외를 특별한 방식으로 처리하고 싶다면 미처리 예외의 핸들러를 따로 등록할 수 있다. 이때는 exception 헤더 파일에 선언되어 있는 다음 함수를 사용하는데 인수로 void func(void)타입 (terminate_handler)의 함수 포인터를 전달한다. 이후 미처리 예외가 발생할 경우 지정한 핸들러 함수가 호출된다.

terminate_handler set_terminate(terminate_handler ph)

  • 예제 terminate

#include <iostream>

#include <exception>

using namespace std;

   

// 디버깅하지 않고 실행해야 출력됨

// Ctrl+F5

void myterm()

{

puts("처리되지 않은 예외 발생");

exit(-1);

}

   

void main()

{

set_terminate(myterm);

try

{

throw 1;

}

catch(char *m)

{

}        

}

catch에는 정수형을 받는 부분이 없으므로 이 예외는 미처리 예외이다. 따라서 미리 지정한 myterm 함수가 호출된다.

임의의 객체를 받으려면 catch(...)을 사용하는데 이때는 ...은 앞부분의 catch에서 처리되지 않은 모든 예외를 의미한다. catch(...)은 예외가 발생했다는 것만 알 수 있으며 어떤 예외가 왜 발생했는지는 알지 못하기 때문에 잘 사용되지 않는다.

catch(...)

{

puts("무언가 잘못됐다.");

}

catch(...)은 반드시 모든 catch의 끝에 와야 한다.

부모 클래스 타입, 자식 클래스 타입을 받는 핸들러가 둘 있다면 자식을 처리하는 핸들러가 먼저 나와야 한다.

   

catch는 컴파일러의 암시적인 타입 변환은 동작하지 않는다.

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

나. 예외 지정

함수를 작성할 때 함수의 원형 뒤쪽에 이 함수 실행 중에 발생할 수 있는 예외의 종류를 지정할 수 있다.

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

   

가능한 예외으 종류가 두 가지 이상일 경우 괄호 안에 예외 타입을 콤마로 구분해서 나열한다.

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

   

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

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

   

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

void func(int a, int d)

   

함수 원형에 던질 수 있는 예외의 종류를 지정하는 것은 문서화의 의미가 있는데 일종의 주석이라고 보면 된다. 이 함수를 사용하는 사람에게 어떤 종류의 예외가 발생할 수 있는지를 알려 주며 개발자는 원형 뒤쪽의 타입에 대해 적절한 catch문을 작성할 수 있다.

   

만약 지정하지 않은 예외가 발생한다면 이 때는 unexpected라는 함수가 호출되어 미지정 예외를 처리한다. unexcepted는 디폴트로 terminate를 호출하여 프로그램을 강제로 종료하는데 다음 함수를 사용하며 ㄴ미처리 예외 핸들러를 변경할 수 있다.

unexpected_handler set_unexpected(unexpected_handler ph)

unexpected_handler 타입은 인수도 리턴값도 없는 함수 포인터 타입이다.

  • 예제 unexpect

#include <iostream>

#include <exception>

using namespace std;

   

void myunex()

{

puts("발생해서는 안되는 에러 발생");

exit(-2);

}

   

void calc() throw(int)

{

throw "string";

}

   

void main()

{

set_unexpected(myunex);

try

{

calc();

}

catch(int)

{

puts("정수형 예외");

}

puts("프로그램종료");

}

비주얼 C++은 미지정 예외 핸들러를 지원하지 않으므로 위예제는 제대로 컴파일되지 않는다.

다. 예외의 비용

프로그램의 안정성과 유지, 보수의 편의성은 증가하지만 프로그램이 비대해지고 느려지는 반대 급부를 쉽사리 무시할 수는 없다.

예외 처리 구문에 의한 성능 저하는 상당한 정도인데 특히 스택 되감기 기능은 호출한 모든 스택을 정리하는 대공사를 한다는 점만 봐도 얼마나 성능에 취약할지 상상이 간다. 물론 예외가 발생하지 않는다면 이런 속도상의 성능 저하는 거의 없으며 실제로 예외 발생 확률은 무척 낮다.

그러나 try, catch라는 키워드를 쓰는 것만으로 프로그램의 용량은 무시못할 정도로 비대해지는 또다른 문제가 있다. 왜냐하면 발생 빈도가 아무리 희박하다 하더라도 예외가 발생했을 때의 코드를 모조리 작성해 넣어야 하기 때문이다. 그래서 성능이 아주 중요하다면 C++의 예외 처리 기능을 사용하지 말아야 하며 전통적인 if문을 사용하는 것이 더 바람직할지도 모른다.

함수 내부에서 예외가 발생했을 때 호출원을 거꾸로 거슬러 올라가면서 스택을 정리하고 모든 객체를 파괴하는 것은 멋진 기능이기는 하다. 그러나 동적으로 할당된 메모리는 그렇지 못하다.

  • 예제 exdynamic

#include <iostream>

   

class some{};

   

void calc() throw(int)

{

some obj;

   

char *p = (char*)malloc(1000);

   

if(true/*예외 발생*/) throw 1;

free(p);

}

   

void main()

{

try

{

calc();

}

catch(int)

{

puts("정수형 예외");

}

}

throw는 남은 뒷부분의 코드를 무시하고 무조건 예외 핸들러로 점프해 버린다. 이 문제를 해결하려면 포인터처럼 동작하며 스스로 할당된 메모리를 해제하는 스마트 포인터(auto_ptr)를 사용할 수 있다.

C++의 예외 처리 구문은 클래스 템플릿에는 쓸 수 없는데 템플릿으로 전달되는 인수의 타입에 따라 발생할 수 있는 예외가 너무 다양해 언제 어떤 예외가 발생할 것인지를 도저히 예측할 수 없기 때문이다. 또한 예외 처리 구문은 멀티 스레드 환경에서 여러 가지로 문제가 있는데 안 그래도 복잡한 멀티 스레드의 동기화 문제를 더 복잡하게 만든다.

예외 처리 기능은 기본적으로 예외가 발생했을 때 적당한 핸들러를 찾아 점프하는 기능이다. 제어를 옮길뿐이지 그 자체의 예외를 복구하지는 못한다.

  • 예제 extretry

#include <iostream>

   

void main()

{

int i;

   

try

{

printf("1~100싸이 정수 입력 : ");

scanf("%d", &i);

if( i < 1 || i > 100 ) throw i;

printf("입력한 수 = %d\n", i);

}

catch(int i)

{

printf("%d는 1~100 사이의 정수가 아님\n", i);

}

}

 

반응형

'책정리 > 혼자 연구하는 C,C++ 2' 카테고리의 다른 글

34장 네임 스페이스  (0) 2015.03.10
33장 타입 정보  (0) 2015.03.09
31장 템플릿  (0) 2015.03.05
30장 다형성  (0) 2015.03.03
29장 상속  (0) 2015.02.28