▶일반적인 예외 처리 방법들
일반적인 예외 처리 방법들을 소개하기 위한 분모(bottom)가 0이 되면 안 되는 Rational 클래스를 만들겠습니다.
#pragma once
#include <iostream>
#include <string>
using namespace std;
struct NegBotException {
int top, bottom;
NegBotException(int t = 1, int b = 0) : top(t), bottom(b) {}
};
class Rational
{
int top; // 유리수의 분자
int bottom; // 유리수의 분모(0이 아니어야 함)
public:
double real() {
return (double)top / bottom; //bottom이 0이면 안 됨
}
};
예외 상황에 대한 처리 방법에는 다음과 같이 여러가지가 있습니다.
1. 예외가 발생하지 않을 것이라고 가정하고 책임을 사용자에게 맡김
제목부터 하면 안되는 방법이라는 느낌이 들 겁니다.
이 방법은 개발자가 예외가 발생하지 않는다고 가정하고 클래스를 만들면,
모든 사용자가 신경 쓰면서 사용해야 합니다.
결국, 분모가 항상 0이 되지 않게 사용자들이 알아서 신경 써야 되는 최악의 방법입니다.
2. 예외 메시지 출력
double real() {
if (bottom == 0) cout << "예외 발생: 분모가 0입니다. "
return (double) top / bottom;
}
사용자에게 예외의 종류를 알려주고 처리를 요청할 수 있지만 상황을 직접 처리하는 것은 아닙니다.
3. 특수한 값이나 예외 코드를 반환
double real() {
if (bottom == 0) return 0.0;
return (double)top / bottom;
}
예외 상황이 발생하면 특수한 값을 출력하는 방법도 많이 사용됩니다.
예를 들어 파일 열기 함수인 fopen()을 사용하였는데, 만약 읽으려는 파일이 없으면 NULL이란 값을 반환합니다.
또는 real() 함수처럼 분모가 0이면 0.0을 반환할 수도 있습니다.
문제는 이 함수는 항상 double 값을 반환한다는 것입니다.
따라서 0이 반환되면 예외 상황에 따른 반환인지 분자가 0이어서 발생한 반환인지 결과를 구분할 수 없습니다.
4. 오류 확인 변수(error flag)의 사용
bool bError = false; //전역 변수
double real() {
if (bottom == 0) bError = true;
return (double) top / bottom;
}
전역 변수로 선언된 예외 확인용 변수 값으로 오류인지 확인할 수 있습니다.
또는, 전역 변수가 싫다면, 그 함수의 매개변수로 참조형 플래그(flag)를 추가로 전달할 수 있습니다.
그리고 예외가 발생하면 이 값을 변경합니다.
double real(bool& bError) {
if (bottom == 0) bError = true;
return (double) top / bottom;
}
그러나 이 방법은 필요 없는 매개변수가 추가되어 함수의 사용을 복잡하게 만든다는 단점이 있습니다.
5. 단정(assertion) 검사와 실행 중지
double real() {
assert(bottom!= 0)
return (double) top / bottom;
}
예외 상황에 대한 또 하나의 대응은 단순히 실행을 중지시키는 것입니다.
assert 매크로를 사용하여 만약, bottom!= 0이 거짓이면 메시지를 출려하고 프로그램을 중지합니다.
보통 출력되는 메시지에는 파일 이름과 줄 번호가 포함됩니다.
6. 예외 처리 함수 지정
class Rational
{
int top; // 유리수의 분자
int bottom; // 유리수의 분모(0이 아니어야 함)
public:
double real() {
if (bottom == 0) exit(0); //종료 함수
return (double)top / bottom;
}
}
void onExit() { cout << "프로그램이 종료됩니다!" << endl; }
void main()
{
atexit(onExit);
Rational r;
cout << "r 입력(a/b): ";
cin >> r;
cout << "r = " << r << " = " << r.real() << endl;
}
예외 상황을 처리하기 위해 예외 처리 함수(exception handler)를 지정하는 것도 가능합니다.
위의 코드는 atexit() 함수를 이용하여 프로그램이 종료되기 직전에 특정한 함수를 호출합니다.
정상적인 입력에 대해서는 main 함수가 종료될 때 onExit가 호출되지만 예외상황에서는 real() 함수에서
exit(0)가 호출되어 바로 이 함수가 실행됩니다.
▶C++의 예외 처리 방법
C++에서는 try, catch, throw라는 키워드를 이용하여 예외를 처리합니다.
기본 형식은 아래와 같습니다.
try
... // 예외가 발생할 수 있는 코드
if(예외 조건)
throw 예외
}
catch{
... // 예외를 처리하는 코드
}
- 만약 try 블록에서 예외가 발생하면 catch 블록의 코드가 실행되고, 다음으로 그다음 코드가 실행됩니다.
- try 블록 다음에는 최소한 하나 이상의 catch 블록이 있어야 합니다.
- 예외가 없으면 catch 블록을 무시하고 바로 다음 코드를 실행합니다.
- catch는 함수가 아니지만 매개변수를 받습니다. 이 경우 전달되는 매개변수의 자료형이 일치하는 예외만을 처리하게 됩니다.
class Rational
{
int top; // 유리수의 분자
int bottom; // 유리수의 분모(0이 아니어야 함)
public:
double real() {
try {
if (bottom == 0) throw('E');
if (bottom < 0) throw(bottom);
}
catch (char c) { cout << "예외 발생: 분수에서 분모가 0입니다.\n"; }
catch (int bot) { cout << "예외 발생: 분모가 음수입니다.\n"; }
catch (...) { cout << "예외 발생: 모든 예외를 처리합니다.\n"; }
return (double)top / bottom;
}
- 분모가 0이면 'E'를 던지고(throw) catch (char c) 블록이 받습니다.
- 분모가 음수이면 정수형 bottom을 던지고 catch (int bot) 블록이 받습니다.
- 모든 예외를 잡고 싶다면 매개변수 위치에... 을 표시하면 됩니다.
명심해야 할 것은 모든 예외를 잡는 블록을 예외 처리 블록들 중에서 가장 아래에 두어야 합니다.
만약 앞에 둔다면 모든 예외가 catch (...) 블록에 의해 처리되기 때문입니다.
따라서, 범위가 작은 것부터 범위가 큰 순서로 예외 처리 블록을 배치해야 합니다.
▶예외 클래스를 만들어 사용하기
C++의 예외 처리 키워드는 어떤 자료형도 던질 수 있으므로 예외 클래스를 만들어 객체를 던질 수도 있습니다.
이것은 예외에 대한 정보들을 묶어서 하나의 객체로 만들어 던지는 것입니다.
다음은 분모가 음수인 경우를 위한 NegBotException이란 예외 클래스를 만들어 사용하는 예를 보여줍니다.
catch 블록에 예외에 대한 더 많은 정보를 전달할 수 있습니다.
struct NegBotException {
int top, bottom;
NegBotException(int t = 1, int b = 0) : top(t), bottom(b) {}
};
class Rational
{
int top; // 유리수의 분자
int bottom; // 유리수의 분모(0이 아니어야 함)
public:
double real() {
try {
if (bottom < 0)
throw(NegBotException(top, bottom));
}
catch (NegBotException e) {
cout << "예외 발생: 분모가 음수입니다. "
<< -e.top << '/' << -e.bottom << "이 바람직합니다.\n";
}
return (double)top / bottom;
}
};
▶예외 전달
1. 예외 전달 필요성
사실 위의 예제 코드들에서 try, throw, catch라는 키워드를 꼭 쓸 필요는 없습니다.
그냥 if문으로 조건 따져도 결과가 동일합니다.
그렇다면 우리는 왜 복잡하게 예외 처리 기법을 사용해야 할까요?
예외 처리의 핵심은 예외의 전달 기능을 이용하는 것입니다.
- 어떤 함수에서 예외 상황이 발생했는데 그 함수 안에서 처리할 수 있다면 예외 처리 기법을 사용하지 않는 것이 좋습니다. 하지만 만약, 그 함수의 호출자마다 그 예외를 처리하는 방법이 다르다면 반드시 예외 처리를 사용하는 것이 좋습니다. 함수에서 발생한 예외를 호출한 코드에 전달해 줄 수 있는 것입니다. (자료형마다 다르게 예외 처리해주는 걸 말하는 것 같습니다)
- 예외의 전달은 프로그램 코드와 오류 처리 코드를 분리하는데 유용합니다.
이를 통해 프로그램의 유지 보수가 용이하게 되고, 코드의 가독성이 좋아집니다. - 큰 프로젝트의 여러 클래스에서 발생하는 공통적인 예외가 있다면 이것은 예외 클래스로 작성하고, 예외 처리 기법을 사용하는 것이 좋습니다. 그러나 개별 함수에서 발생하는 간단한 예외 상황은 예외를 발생시키지 않고 그 함수 안에서 처리하는 것이 가장 좋습니다
2. 예외의 전달 처리
만약 예외를 던지기만 하고(throw) 처리하지 않으면 예외 처리의 책임이 해당 함수를 호출한 함수로 넘어갑니다.
즉, 던져진 예외는 처리될 때까지 함수 호출 체인을 따라가면서 일치하는 자료형의 예외를 찾아 처리될 때까지 진행합니다.
만약, 마지막까지 처리되지 않는다면 실행오류가 발생합니다.
아래의 예외처리의 예를 Rotional클래스에서 던지고 main함수를 개발자가 구현한 코드라 생각하고 보면 이해가 편할 겁니다.
struct NegBotException {
int top, bottom;
NegBotException(int t = 0, int b = 1) : top(t), bottom(b) {}
};
class Rational
{
int top, bottom;
public:
Rational(int t = 0, int b = 1) : top(t), bottom(b) { }
double real() {
if (bottom == 0) throw('E'); //던지기만 해서 예외 처리를 호출부터에 처리해야 함
return (double)top / bottom;
}
//연산자 오버로딩
friend ostream& operator<<(ostream& os, const Rational& f) {
os << f.top << "/" << f.bottom;
return os;
}
//연산자 오버로딩
friend istream& operator >> (istream& is, Rational& f) {
is >> f.top >> f.bottom; // 분자 / 분모 읽기
if (f.bottom == 0) throw('E');
if (f.bottom < 0) throw(NegBotException(f.top, f.bottom));
return is;
}
};
void main()
{
Rational r;
cout << "r 입력(a/b): ";
try {
cin >> r;
}
catch (char c) {
cout << "예외 발생: 분수에서 분모가 0입니다.\n";
cout << "더 진행할 수 없습니다.\n";
exit(0);
}
catch (NegBotException e) {
r = Rational(-e.top, -e.bottom);
cout << "예외 발생: 분모를 양수로 변환합니다.\n";
}
cout << "r = " << r << endl;
cout << "r = " << r.real() << endl;
}