본문으로 바로가기
반응형

예외

전통적인 예외 처리

예외는 전통적인 실행을 방해하는 조건이나 상태를 의미한다. 프로그램을 잘못 작성해서 오동작하는 에러와는 다르다.

에러는 발견하는 즉시 수정해야하고 미처 발견하지 못하면 버그가된다. 예외는 버그와는 달리 프로그램을 제대로 만들었지만 원하는 대로 동작하지 못하게 방해하는 불가항력적인 상황이다.

 

아무리 코드를 잘 작성해도 미래의 상황까지 예측할 수 없기 때문에 예외는 항상 발생한다.

프로그램의 예외는 언제 어디서 발생할지 모르기때문에 적극적으로 대처해야한다. 잘못된 입력은 사용자에게 알려 재입력을 요구하고 실패한 동작은 원인은 제거한 후 성공할 때까지 재시도해야한다. 

 

또 메모리 요구량이 잘못되었거나 할당에 실패할수도 있고, 사용자가 엉뚱하게 입력했을때도 예외가 발생할 수 있다.

이러다 보니 C에서는 모든 예외에 해당되면 if문으로 처리해서 종료하거나 에러 메세지만 뜨게하는게 전부였다.

그러나 C++에서는 if문과는 질적으로 다른 혁신적인 예외처리문을 사용할 수 있다.

 

 

C++ 의 예외처리

C++은 예외 처리를위해 다음 세 키워드를 도입했다.

 

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

- throw : 예외 발생시 예외를 던져 catch문으로 점프한다. throw 다음에 예외를 설명하는 값이나 객체를 전달한다.

- catch : 예외를 받아서 처리하는 예외 핸들러이며 이 안에 예외 처리 코드를 작성한다. catch 다음에 받고자 하는 예외를 명시하여 throw에서 던진 예외를 받는다.

 

try 블록 안에서 연산을 하다가 에러 상황을 만나면 throw로 예외를 던지며 catch에서 이 예외를 받아 처리한다.

하나의 try 블록에서 여러 종류의 예외가 발생할 수 있는데 이때는 예외의 종류에따라 catch 블록 여러 개를 나열한다.

다음 예제는 정수 2개를 입력받아 나눗셈을하되 피젯수는 야수만 가능하다는 규칙을 추가한 것이다.

// 파일이름 : trycatch.cpp
#include <stdio.h>

int main()
{
	int a, b;
	try {
		printf("나누어질 수 입력 : ");
		scanf_s("%d", &a);
		if (a < 0) throw a;

		printf("나누는 수 입력 : ");
		scanf_s("%d", &b);
		if (b == 0) throw "0으로는 나눌 수 없습니다.";

		printf("나누기 결과는 %d입니다.", a, b, a / b);
	}

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

	catch (const char* message)
	{
		puts(message);
	}
}

정상입력
음수를 입력했을때 예외 처리
0으로 나누려고했을때 예외처리

입력 및 연산 코드는 모두 try 블록에 작성되어 있다. a를 입력받되 이 값이 음수이면 a를 던진다.

throw 는 정수값을 받는 catch 블록을 찾아 점프하며 throw 이후의 코드는 무시된다. 

순차적으로 실행되는 코드 흐름에서 앞부분이 잘못되면 뒷부분도 제대로 실행되지 않는 것이 보통이며 연산을위한 입력값이 잘못되었으니 뒤쪽의 코드는 더 실행할 필요가 없다.

catch에서 a값을 전달받아 음수는 안된다는 에러를 출력하고 전체 블록이 종료된다.

 

a가 양수이면 다음 단계로 넘어가 b를 입력받는다. b가 0이면 나눌 수 없다는 문자열 예외를 던진다.

throw는 char *를 받는 catch문으로 점프하여 에러 메세지를 출력하고 전체 블록이 종료된다.

두 입력값 중 하나라도 잘못되면 나눗셈은 수행되지 않는다.

a, b 가 4, 2를 입력해서 정상적으로 입력되면 모든 예외 점검문을 통과하여 a/ b를 연산한 결과를 출력한다.

 

catch 블록의 코드는 잘 발생하지 않는 비정상적인 상황을 처리하며 프로그램의 논리와는 큰 상관이 없다. 핵심 코드와 어쩌다 발생하는 예외 처리 코드가 분리되어 깔끔하면서 이 코드를 분석하는 사람은 try 블록만 집중적으로 분석하고 관리하면 된다. 코드의 실행흐름은 다음과 같다.

예외가 발생하지 않으면 catch 블록은 모두 무시되며 try 블록 다음부터 실행을 계속한다.

throw가 예외 객체를 던지고 catch가 받는데 마치 함수로 인수를 전달하는 것과 비슷하다. catch는 전달된 예외 객체를 통해 상황을 판단하여 예외를 처리한다.

 

catch는 throw에 의해 호출되는 함수에 비유되며 함수가 오버로딩될 수 있듯이 catch도 예외 타입에따라 여러 버로 작성한다. throw가 던지는 예외의 타입과 일치하는 catch가 호출된다. catch가 다시 리턴하지는 않으므로 throw는 무조건 분기문인 goto와 유사하다고 볼 수 있다.

 

 

함수와 예외

예외를 던지는 throw문은 try 블록 안에 있는 것이 원칙이지만 함수 안에서는 단독으로 올 수 있다.

이 경우 함수가 직접 예외를 처리하지 않고 함수를 호출한 곳에서 처리한다.

따라서 호출원이 try, catch 블록을 작성하여 예외를 처리해야한다.

// 파일이름 : throwfunc.cpp
#include <stdio.h>

void divide(int a, int d)
{
	if (d == 0) throw "0으로는 나눌 수 없습니다";
	printf("나누기 결과 = %d입니다.\n", a / d);
}

int main()
{
	try
	{
		divide(10, 0);
	}
	catch (const char* message)
	{
		puts(message);
	}
	divide(10, 5);
}

divide 함수는 두 개의 인수 a, d를 받아 나눗셈 결과를 출력하되 d가 0이면 예외를 던진다.

main은 함수를 호출하는 구문을 try 블록으로 감싸 예외를 받아야한다.

첫 번째 호출은 의도적으로 0을 전달하여 예외를 일으켰다. divide의 throw 구문은 호출원으로 돌아와 대응되는 catch문을 찾아 예외를 처리한다. 함수에서 문자열을 던졌으므로 나눌 수 없다는 에러 메세지가 출력된다.

 

스택에는 함수의 호출 정보를 저장하는 스택 프레임이 생성되며 함수가 리턴할 때 정확하게 호출 전의 상태로 돌아간다.

함수 실행중에 예외가 발생하여 호출원의 catch로 바로 점프하면 스택이 항상성을 잃어버려 엉망이 되고 만다.

그래서 throw는 호출원으로 돌아가기 전에 자신의 스택 프레임을 정리하는데 이를 스택되감기라고한다.

 

try 블록 안의 divide(10, 0)에서 발생한 예외는 catch 블록에서 처리되며 이후 그 다음 문장인 divide(10, 5)는 잘 실행된다. 예외에 의해 함수가 강제 리턴했음에도 main의 다음 코드가 아무 이상 없이 잘 실행되는 이유는 스택 프레임을 호출 전의 상태로 복구하기때문이다.

 

main의 코드를 다음과 같이 수정해보자.

int main()
{
   divide(2, 0);
}

이 경우 예외를 받아줄 catch 문이 없으므로 디폴트처리되어 프로그램이 강제 종료된다.

 

다음의 경우도 마찬가지이다.

int main()
{
    try
    {
        divide(20, 0);
    }
    catch(int code)
    {
        printf("%번 에러가 발생했습니다.\n", code);
    }
}

함수 호출문이 try 블록에 포함되어 있고 catch문도 있지만 divide 함수가 던지는 char * 타입을 받는 catch 블록이 없다.

발생한 예외를 아무도 처리하지 않으므로 프로그램은 강제 종료된다.

 

함수 호출 단계가 깊어도 throw는 정확히 예외 처리 블록을 찾는다. throw는 대응되는 catch 블록을 찾기 위해 스택에서 위쪽 함수를 찾아 올라가며 스택을 차례대로 정리하는데 이때 각 함수의 지역 객체도 정상적으로 잘 파괴된다.

다음 코드로 스택을 되감는 절차를 알아보자.

// 파일이름 : Stackunwinding
#include <stdio.h>

class C
{
	int a;
public:
	C() { puts("생성자 호출"); }
	~C() { puts("소멸자 호출"); }
};

void divide(int a, int d)
{
	if (d == 0) throw "0으로는 나눌 수 없습니다.";
	printf("나누기 결과 = %d입니다.\n", a / d);
}

void calc(int t, const char* m)
{
	C c;
	divide(10, 0);
}

int main()
{
	try 
	{
		calc(1, "계산");
	}
	catch (const char* message)
	{
		puts(message);
	}

	puts("프로그램이 종료됩니다");
}

main이 직접 divide를 부르는 것이 아니라 중간에 calc 함수가 더 있는 것에 주목해보자.

calc는 지역 객체 c를 생성한 후 예외를 일으키는 divide(10, 0)을 호출하는데 이 함수 실행중에 문자열 예외가 발생한다.

 

divide는 내부에 catch 블록이 없어 자신의 스택 프레임을 정리하고 호출원인 calc로 돌아간다.

calc도 마찬가지로 catch 블록이 없으므로 같은 방식으로 스택에 정리한다.

이때 인수 t, m, 지역 변수 c도 같이 소멸되며 소멸 과정에서 소멸자가 호출된다.

스택만 정리하고 객체를 제대로 정리하지 않으면 프로그램이 불안정해지므로 호출 전의 상태로 정확하게 복구해야한다.

 

main으로 리턴하면 catch 블록으로 점프하여 메세지를 출력함으로써 예외를 처리한다.

비록 나눗셈은 실패했지만 예외를 잘 처리했으면 프로그램은 정상적으로 계속 실행할 수 있다.

예외를 일으킨 함수와 중간함수까지 스택과 객체를 호출 전의 상태로 정리했기 때문이다.

 

 

중첩예외처리

예외 처리 구문은 중첩 가능해서 try 블록 안에 또 다른 try 블록이 들어갈 수 있다. 큰 작업의 일부를 처리하는 중에 다른 예외가 발생할 수 있다면 언제든지 try 블록을 구성하면된다.

// 파일이름 : nesttry.cpp
#define _CRT_SECURE_NO_WARNINGS
#include <stdio.h>
#include <conio.h>
#include <string.h>

int main()
{
	int num;
	int age;
	char name[128];

	try
	{
		printf("학번 입력 : ");
		scanf("%d", &num);
		if (num <= 0) throw num;
		try
		{
			printf("이름 입력 : ");
			scanf("%s", 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);
	}
}

정상으로 출력
예외로 출력

바깥쪽 try 블록에서 학번 num을 입력받다가 음수일 경우 num을 예외로 던지며 바깥쪽의 catch(int n) 블록이 이 예외를 받아 음수는 불가능하다는 메세지를 출력한다.

학번이 제대로 입력되면 이름을 입력받는데 이때도 길이가 너무 짧아서는 안된다.

try 블록 안에서 또 다른 예외가 발생할 수 있으므로 다시 try 블록을 감싸고 catch(const char *)가 받아서 처리한다.

 

나이가 음수일때는 안쪽의 catch(int)로 예외를 던진다. 안쪽에서 이 예외를 직접 처리할 수도 있지만 바깥쪽에 같은 타임의 예외 처리기가있으면 밖으로 던져버리면 된다. catch 블록에서 예외를 다시 던질때는 객체를 지정할 필요없이 throw 명령만 단독으로 사용한다.

 

한 함수 내에서는 사실 굳이 try문을 중첩시킬 필요없이 하나의 try 블록에 catch 블록을 여러개 배치하면 된다.

그러나 이미 예외처리 블록을 가진 함수를 호출할때는 자연스럽게 중첩이 발생한다. 이런 경우를위해 예외 중첩을 지원해준다.

 

 

예외 객체

예외를 전달하는 방법

함수 실행중에 계속 진행할 수 없는 상황이 발생하면 즉시 리턴하되 어떤 종류의 에러가 왜 발생했는지 상세히 알아야할 것이다. 호출원은 함수의 리턴값을 보고 에러의 종류를 파악하여 다음 조치를 취한다.

함수가 에러를 보고하는 전통적인 방법은 에러 코드를 리턴하는 것이다.

// 파일이름 : ExecptionReturn.cpp
#include <stdio.h>

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

	// 여기까지 왔으면 무사히 작업 완료
	return 0;
}

int main()
{
	int e;

	e = report();
	switch (e)
	{
	case 1:
		puts("메모리가 부족합니다.\n");
		break;
	case 2:
		puts("연산 범위를 초과했습니다.\n");
		break;
	case 3:
		puts("하드 디스크가 까득 찼습니다.\n");
		break;
	default:
		puts("작업을 완료했습니다\n");
		break;
		
	}
}

이 예제의 report() 함수는 통계를 내고 파일로 출력하되 작업을 무사히 완료했으면 0을 리턴하고 에러 발생시 1에서 3사이의 에러 코드를 리턴한다. 에러 코드는 일종의 약속이며 함수 레퍼런스에 각 에러 코드의 의미를 문서화해둔다.

호출원은 리턴값을 점검하여 에러의 유형에따라 필요한 조치를 취한다.

 

예외처리구문을 사용하면 예외로 에러 사실을 통보할 수 있으니 리턴값은 고유의 작업 결과 보고용으로 사용할 수도 있다. 리턴값이 아닌 throw 구문으로 에러를 보고할 수 있고 throw 자체가 함수를 종료하므로 별도의 return 문을 쓸 필요도 없다. 다음 예제는 에러의 종류를 열거형으로 정의하고 throw문으로 예외를 던진다.

// 파일이름 : ExceptionEnum.cpp
#include <stdio.h>

enum E_Error{OUTOFMEMORY, OVERRANGE, HARDFULL};

void report() throw(E_Error)
{
	if (true /*예외발생*/) throw OVERRANGE;

	// 여기까지 왔으면 무사히 작업 완료
}

int main()
{
	try
	{
		report();
		puts("작업을 완료했습니다.");
	}

	catch (E_Error e)
	{
		switch (e)
		{
		case OUTOFMEMORY:
			puts("메모리가 부족합니다.");
			break;
		case OVERRANGE: 
			puts("연산 범위를 초과했습니다.");
			break;
		case HARDFULL:
			puts("하드디스크가 가득 찼습니다.");
			break;
		} 
	}
}

report가 예외를 던지므로 이 함수 호출문은 반드시 try 블록안에 작성하고 catch 블록에서 예외의 종류에 따라 처리한다. 앞 예제보다는 좀 나아졌지만 열거형도 기억하기 어렵다는 면에서 여전하며 호출원이 일일이 의미를 분석해야 하니 길이도 비슷하다.

 

throw로 던질 수 있는 예외의 타입에 제한이 없으니 아예 객체를 던지는 것이 더 활용성이 높다.

클래스는 에러 메세지를 포함할 수 있고 스스로 예외를 처리하는 함수까지 가질 수 있어 호출원에서 사용하기 어렵다.

// 파일이름 : ExceptionObject.cpp
#include <stdio.h>

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 report()
{
	if (true /*예외 발생*/) throw Exception(3);

	// 여기까지왔으면 무사히 작업 완료
}


int main()
{
	try
	{
		report();
		puts("작업을 완료했습니다.");
	}
	catch (Exception& e)
	{
		printf("에러 코드 = %d =>", e.GetErrorCode());
		e.ReportError();
	}
}

Exception 예외 클래스는 에러 코드와 생성자, 에러 코드를 조사하는 함수와 메세지를 출력하는 함수를 캡슐화한다.

report 함수는 에러 발생시 예외 객체를 생성하여 던지고 catch는 이 객체를 받아 에러 코드를 얻으며 메세지 출력까지 예외 객체에게 시킨다.

예외와 관련된 모든 정보와 동작까지 완벽하게 캡슐화하여 사용하기 쉽다.

 

그래서 throw가 던지는 예외 정보를 예외 객체라고 부르며 정수나 문자열도 객체의 일종이다.

throw는 예외 객체를 생성하여 던지며 catch는 레퍼런스로 받는다. 값으로 받으면 속도가 느리고 포인터로 받으면 '->' 연산자를 사용해야 하니 직관적이지 못하다. 그래서 임시 객체를 던지고 레퍼런스로 받는다

 

 

예외 클래스 계층

예외를 클래스로 정의하면 객체 지향의 여러가지 기법을 활용할 수 있다.

비슷한 종류의 예외라면 상속 계층을 구성하여 반복된 코드를 줄이고 에러 처리 함수를 가상으로 선언하면 다형성의 이점도 누릴 수 있다. 다음 예제는 100 이하의 양의 짝수만 입력받으며 그 외의 숫자는 예외 처리한다.

// 파일이름 : InheritException
#include <stdio.h>

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);
	}
};

int main()
{
	int n;

	for (;;)
	{
		try
		{
			printf("숫자를 입력하세요(0:종료) : ");
			scanf_s("%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 함수를 가상으로 선언했다. 이 클래스를 파생시켜 100을 초과하는 수에 대한 예외를 처리하는 ExTooBig 클래스를 정의하며 이 클래스를 다시 파생시켜 홀수 예외를 처리하는 ExOdd를 정의한다. 

루트 클래스의 PrintError함수가 가상으로 정의되어 있으므로 파생 클래스의 PrintError 함수도 동적으로 결합한다.

 

main에서 숫자를 입력받아 조건에 따라 적절한 예외 객체를 생성하여 던진다. 예외 종류에 따라 각 예외 객체를 받는 catch 블록을 일일이 작성할 필요 없이 루트 타입인 ExNegative 객체만 받아 처리하면 된다.

발생 가능한 예외가 ExNegative와 Is a 관계여서 파생 객체를 모두 받을 수 있고 각 객체에 대한 PrintError 함수가 예외 타입에 따라 동적으로 호출된다.

 

예외끼리 상속계층을 이루고 가상 함수에 의해 예외 타입에 맞는 적절한 함수가 자동으로 호출되니 예외의 종류를 판별하는 일은 신경쓰지 않아도 된다. 비슷한 예외를 하나의 catch 블록으로 통합처리할 수 있어 코드가 간단해지고 확장이나 변형도 쉽자.

객체 지향의 이점을 충분히 활용하기위해 에러 코드보다 가급적 객체로 예외를 정의하는 것이다.

 

 

예외의 캡슐화

클래스 동작 중에 특정한 예외가 발생할 수 있다면 이 예외에 대한 모든 처리도 클래스 안에 완벽하게 통합해 넣는 것이 좋다. 예외 클래스를 지역적으로 선언해 두면 어떤 상황에서라도 클래스 스스로 예외를 처리할 수 있어 안전성과 재활용성이 향상된다.

// 파일이름 : ExceptionClass.cpp
#include <stdio.h>

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);
	}
};

int main()
{
	MyClass m;
	m.calc();
	try
	{
		m.calc2();
	}
	catch (MyClass::Exception& e)
	{
		printf("에러 코드 = %d => ", e.GetErrorCode());
		e.ReportError();
	}
}

MyClass는 예외를 처리하는 Exception 지역 클래스를 포함하며 이 클래스는 에러 코드와 에러 메세지 출력 기능을 제공한다. calc 멤버 함수 동작 중에 예외가 발생하면 Exception 객체를 생성하여 던지며 함수 내부에서 이 예외를 직접 처리한다. 클래스 내부에서 모든 것을 처리하고 있어 외부에서는 에러 처리에 신경쓸 필요 없이 그냥 호출만 하면 된다.

 

내부적으로 처리하기 곤란하거나 외부에 예외 발생 사실을 알려야한다면 멤버 함수가 직접 처리하지 말고 예외 객체를 던진다. calc2 함수가 이런 식으로 동작하는데 이 예외는 호출원인 main에서 처리한다.

단, 이것이 가능하려면 지역 클래스인 Exception을 외부에서 참조할 수 있도록 public 으로 선언해야한다.

 

 

생성자와 연산자의 예외

생성자와 연산자는 일반적인 방법으로는 예외처리하기 어렵다.

생성자는 리턴값의 개념이 없어 에러를 리턴할 방법이 없다. 연산자의 리턴값은 연산 결과이므로 에러로 정의할 수 있는 특이값이 없다.

+ 연산자의 리턴값 -1을 에러로 정의하는 것은 진짜 연산 결과가 -1인 경우와구분되지 않아 불가능하다.

예외 처리 구문은 리턴값에 의존하지 않고 예외 발생시 원하는 곳으로 제어를 옮길 수 있어 생성자와 연산자의 에러처리에 적합하다.

// 파일이름 : CtorException.cpp
#include <stdio.h>

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);
	}
};

int main()
{
	try
	{
		Int100 i(85);
		i += 12;
		i.OutValue();
	}
	catch (int n)
	{
		printf("%d는 100보다 큰 정수이므로 다룰 수 없습니다.\n", n);
	}
}

정상값 출력
101을 넣었을때 예외처리

int100 클래스는 100 이하의 정수를 저장하는 클래스이며 생성자와 += 연산자, 값을 출력하는 OutValue 함수를 정의한다. 생성자는 초기값이 100보다 클 경우 예외를 던져 객체 생성을 중지한다. 안전한 객체 생성을 위해 객체 선언문을 try 블록에 작성하고 catch(int)로 예외를 처리한다.

 

값을 증가시키는 += 연산자는 증가에 의해 100보다 큰 값이 될 때 예외를 던진다. 연산자는 연쇄적인 연산을 위해 객체 자체를리턴하며 특이값을 정의할 수 없어 예외를 던진다. 예외를 처리하지 않으면 잘못된 값을 가지거나 연산 자체를 무시해야한다.

 

 

try 블록 함수

함수 실행 중에 항상 예외가 발생할 수 있다면 함수 본체 자체를 try 블록으로 완전히 묶는다.

함수 실질적인 코드는 모두 try 블록에 작성되어있으므로 본체를 따로 만들 필요없이 try 블록 자체를 함수의 본체로 만들면 된다. 함수의 시작과 끝을 표시하는 { } 괄호를 없애고 try와 catch 블록을 함수의 본체인 것처럼 작성하는 것이다.

 

괴상해보이는 코드지만 이런 이상한 문법은 생성자의 경우, 본체 이전에 실행되는 초기화 리스트까지 예외 처리 블록에 포함시키려면 이런 구문이 필요하다.

// 파일이름 : tryCtor.cpp
#include <stdio.h>

class Int100
{
private:
	int num;

public:
	Int100(int a)
	try : num(a)
	{
		if (a > 100)
		{
			throw a;
		}
	}
	catch(int a)
	{
		printf("%d은 100보다 더 큽니다.\n", a);
	}

	void OutValue()
	{
		printf("%d\n", num);
	}
};

int main()
{
	try
	{
		Int100 i(101);
		i.OutValue();
	}
	catch (int n)
	{
		printf("무효한 객체임.\n", n);
	}
}

생성자 본체가 시작되자마자 try 블록이 나오고 블록 시작 전에 초기화 리스트가 시작된다.

초기화 리스트까지도 예외 처리 블록에 포함시키기 위해 이 표기법이 꼭 필요하다.

초기화 리스트는 본체 이전에 있으므로 기존의 방법으로는 예외 처리구문에 포함시킬 수 없다.

이 예제의 Int100 클래스는 num에 a를 대입하는 코드밖에 없어 사실 예외가 발생할 가능성이 없다.

하지만 부모 클래스로부터 상속받은 멤버나 포함 객체를 초기화하는 중에 예외가 발생할 가능성은 아주 높다.

이럴때는 초기화 리스트까지 예외 처리 블록에 포함시켜야한다.

 

생성자에서 조건을 판단하여 예외를 처리하더라도 이 예외는 자동으로 다시 던져진다.

왜냐하면 생성 단계의 예외는 객체 혼자만의 문제가 아니라 이 객체를 선언한 곳과도 관련이 있어 선언 주체에게도 예외 사실을 알리기 위해서이다. main에서 Int100 객체를 선언하는 문장도 try 블록으로 감싸 객체 생성시의 예외를 처리한다.

만약 이 구문이 빠지면 미처리 예외가되어 프로그램이 뻑난다.

 

 

예외지정

미처리예외

throw로 예외를 던졌는데 받을 catch 블록이 없거나 있더라도 예외 타입이 맞지 않다면 미처리 예외가 된다.

아무도 처리하지 않는 예외는 termivate 함수가 처리하며 내부적으로 abort 함수를 호출하여 프로그램을 강제 종료한다.

 

강제종료가 프로그램에 좋지 않은 것 같지만 개발 중에 문제를 분명히 알리는 것이 차라리 낫다.

미처리 예외를 직접 처리하고 싶다면 다음 함수로 미처리 예외 핸들러를 등록한다. 인수로 void func(void) 타입(terminate_handler)의 함수 포인터를 전달하면 이후부터 미처리 예외가 발생할때 이 함수가 호출된다.

terminate_handler set_terminate(terminate_handler ph)

미처리 예외 핸들러는 아무도 처리하지 않는 예외를 받아 극단적인 예외 처리를 수행한다.

다음 예제는 myterm 함수를 미처리 예외 핸들러로 지정하여 화면에 자신의 죽음을 알리고 종료한다.

// 파일이름 : terminate.cpp
#include <stdio.h>
#include <exception>

void myterm()
{
	puts("처리되지 않은 예외 발생");
	exit(-1);
}

int main()
{
	set_terminate(myterm);
	try
	{
		throw 1;
	}

	catch (char* m)
	{

	}
}

main에서 정수형의 예외를 던졌는데 뒤쪽의 catch 블록은 정수를 받지 않으므로 이 예외는 처리할 주체가 없다.

미처리 예외 발생시 미리 지정해 놓은 myterm 함수가 호출되어 최종 처리를 담당한다.

이때 예외를 발생시킨 함수의 스택 정리 여부는 컴파일러마다 다른데 어차피 종료되는 상황이라 별 의미는 없다.

 

임의의 모든 예외를 다 받으려면 catch(...) 블록을 사용한다. 이 블록은 모든 예외를 다 처리할 수 있지만 어떤 예외가 왜 발생했는지는 정확히 알 수 없다. 예외에 대한 정보는 필요없고 단순히 예외가 발생했다는 사실만 알고 싶을 때 이 구문을 사용한다. terminate는 전역적인 예외 핸들러인데 비해 catch(...)는 국지적인 미처리 예외 핸들러이다.

catch(...)
{
	puts("뭔지는 모르겠지만 일단 예외가 발생했음");
}

컴파일러는 등장하는 순서대로 catch 블록을 점검하여 타입이 일치하는 핸들러를 찾는다. 따라서 특수한 타입이 앞에 와야하며 모든 예외를 받는 catch(...)는 맨 마지막에 와야할 것이다. 여러 개의 예외를 처리할때 catch 블록의 올바른 배치는 다음과 같다.

try{}
catch(int)
catch(char *)
catch(exception)
catch(...)

만약 catch(...) 맨 앞에 있으면 어떤 예외든 이 블록에서 잡아 버리므로 아래쪽의 catch 블록은 있으나 마나이다.

컴파일러는 예외 객체 타입으로 catch 블록은 선택하는데 타입 점검이 엄격하기때문에 암시적 변환은 고려하지 않는다.

 

정확한 예외 처리를 위해 예외 객체의 타입 점검이 굉장히 엄격하다. 예외적으로 void * 타입을 받는 핸들러는 임의의 포인터 타입을 받을 수 있고 부모 타입의 포인터를 받는 핸들러는 자식타입의 객체를 받을 수 있다.

 

 

예외 지정

함수의 원형 뒤쪽에 함수 실행 중에 발생 가능한 예외의 목록을 지정할 수 있다.

인수 목록 다음에 throw(예외 목록) 형식으로 괄호 안에 예외 타입을 적되 여러 개일 경우 콤마로 나열한다.

void sub1(int a, int d) throw(char *)
void sub2(int a, int d) throw(char *, int)

예외를 던지지 않는 함수는 throw()만 적어 괄호 안을 비워둔다. throw 지정이 없는 함수는 임의의 예외를 던질 수 있다는 뜻이다. 다음 두 함수는 원형은 같지만 예외 지정은 완전히 다르다.

void sub3(int a, int d) throw()
void sub4(int a, int d)

sub3은 예외를 던지지 않는 함수이며 sub4는 예외를 던질 수도 있고 아닐 수도 있다. throw 지정은 문버적인 기능은 없으며 어떤 예외를 던지는지 문서화만한다.

 

 

예외 처리의 한계

C++의 예외 처리 기능은 언어에 통합되어 있고 정교하게 잘 만들어졌다는 면에서 쓰기 편하다. 표준을 준수하는 모든 컴파일러에서 쓸 수 있어 이식성을 걱정할 필요가 없다. 그러나 비용이 높고 일부 상황에서 잘 동작하지 않는 한계가 있다. 

 

예외 처리 기능을 사용하면 여기저기 필요한 코드가 삽입되어 프로그램은 커지고 성능은 떨어진다. 특히 스택 되감기는 호출된 모든 함수의 스택을 강제 정리하는 대공사여서 속도가 심하게 떨어진다. 이런 상황이 자주 발생하지 않지만 어쨌든 필요한 코드를 모두 생성해야 하니 프로그램이 비대해질 수 밖에 없다.

 

스택 되감기는 지역 변수를 깔끔하게 정리하고 객체의 파괴자도 정상적으로 잘 호출된다. 호출 단계가 아무리 깊어도 스택의 항상성을 정확히 유지해준다. 그러나 이는 스택 기반의 변수에 한정될 뿐이며 동적으로 할당한 메모리는 정리하지 못하는 한계가 있다.

// 파일이름 : exdynamic.cpp
#include <stdio.h>

class SomeClass {};

void calc() throw(int)
{
	SomeClass obj;
	char* p = new char[1000];

	if (true /*예외발생*/) throw 1;
	delete[] p;
}

int main()
{
	try
	{
		calc();
	}
	catch (int)
	{
		puts("정수형 예외 발생");
	}
}

calc 함수에서 예외 발생시 지역 객체인 obj의 파괴자가 호출되어 객체를 깔끔하게 제거한다.

그러나 new 연산자로 할당한 메모리는 해제되지 않아 메모리 누수가 발생한다. 동적 할당한 메모리는 사용자가 해제하지 않는 한 할당된 채로 계속 남는다. throw는 뒷부분을 무시하고 예외 핸들러로 점프해버리기 때문에 delete[]는 호출되지 않는다.

 

예외 처리 구문은 다른 기능과 충돌을 일으키기도 하는데 대표적으로 템플릿과 잘 어울리지 않는다.

템플릿은 임의의 타입을 지원하여 범용적인 기능을 제공하는데 비해 예외 처리는 엄격한 타입에 기반하기 때문이다.

템플릿은 임의 타입에 대해 쓸 수 있다보니 어떤 예외가 발생할지 예측하기 어렵다. 쉽게말하면 이 둘은 궁합이 잘 맞지 않아 같이 쓰기 어렵다.

 

멀티 스레드 환경에서도 여러가지 문제를 야기하는데, C++의 예외 처리 기능 자체는 멀티 스레드를 고려하여 동기적으로 설계되어 있지만 실제 적용시에는 여러 가지 복잡한 규칙을 따라야하고 주의사항이 많아 예외를 매끈하게 처리하기 쉽지 않다.

 

예외 처리 발생시 적당한 핸들러를 찾아 점프하여 예외를 처리할 뿐 예외를 복구할 수는 없다. catch로 점프해버리면 다시 try블록으로 돌아갈 방법이 없다.

// 파일이름 : exretry.cpp
#include <stdio.h>

int main()
{
	int num;

	try
	{
		printf("1~100 정수 입력 : ");
		scanf_s("%d", &num);
		if (num < 1 || num > 100) throw num;
		printf("입력한 수 = %d\n", num);
	}

	catch (int num)
	{
		printf("%d는 1~100 정수가 아닙니다.\n", num);
	}
}

정상적인 입력 했을시
예외를 발생시켰을시

 

1~100 벗어난 수가 입력되면 예외처리하도록 작성했는데, 틀린 입력은 정확하게 적발하지만 다시 입력하도록 돌아가지는 못한다. 재시도하려면 예외 처리 구문을 반복문으로 감싸 다시 입력하도록 해야한다.

이럴때는 전통적인 루프를 쓸빠에야 그냥 if문으로 예외를 처리하는 것이 더 간편하다.

 

이런 짧은 코드에서는 단순한 for, if문이 더 읽기 쉽고 마음대로 재시도 할 수 있어 예외 처리 구문보다 더 직관적이다.

예외 처리 기능이 최신이라고 해서 반드시 if문보다 더 낫다고 할 수는 없으며 모든 if문을 다 예외처리 구문으로 바꿀수도 없다. 이 외에도 스택 되감기 중 소멸자에서 예외 발생시의 애매함, 예외 핸들러에서 예외가 발생한 지점을 알 수 없다는 한계가 있다.

 

예외 처리 기능은 라이브러리 내의 깊은 호출 단계에서 발생한 예외까지고 잡아낸다는 점에서 훌륭하지만 반대급부가 있으므로 꼭 필요한 때만 사용하는 것이 좋다.

반응형