다음의 예제를 살펴보자.
// main.cpp
#include "include/DynamicArray.h"
#include <iostream>
using namespace std;
int main()
{
DynamicArray arr(10);
bool b;
b = arr.SetAt(5, 0);
if (!b)
cout << "arr[5] fail" << endl;
b = arr.SetAt(25, 0);
if (!b)
cout << "arr[25] fail" << endl;
return 0;
}
// DynamicArray.h
#ifndef TEST_DYNAMICARRAY_H
#define TEST_DYNAMICARRAY_H
class DynamicArray
{
public:
DynamicArray(int arraySize);
~DynamicArray();
bool SetAt(int index, int value);
int GetAt(int index) const;
int GetSize() const;
protected:
int *arr;
int size;
};
#endif // TEST_DYNAMICARRAY_H
// DynamicArray.cpp
#include "../include/DynamicArray.h"
DynamicArray::DynamicArray(int arraySize) : size(arraySize)
{
arr = new int[arraySize];
}
DynamicArray::~DynamicArray()
{
delete[] arr;
arr = nullptr;
}
bool DynamicArray::SetAt(int index, int value)
{
if (index < 0 || index >= GetSize())
return false;
arr[index] = value;
return true;
}
int DynamicArray::GetAt(int index) const
{
return arr[index];
}
int DynamicArray::GetSize() const
{
return size;
}
실행 결과는 다음과 같다.
arr[25] fail
SetAt()
함수에서는 잘못된 인덱스를 받았을 때 계속해서 작업을 진행하는 대신 자신을 호출한 곳에 문제가 발생했음을 알려주어야 한다. 그리고 전통적으로 이런 일은 함수의 반환 값을 통해서 이루어졌다.
하지만 이 방법은 몇 가지 문제점이 있기 때문에, C++
에 새롭게 추가된 구조적인 예외 처리 방법을 적용하는 것이 옳다.
첫번째로 반환 값을 사용한 예외 처리를 사용하게 되면 함수를 호출할 때마다 매번 반환 값을 비교해야 하는 번거로움이 있다.
void UserArray(DynamicArray &arr)
{
bool b;
b = arr.SetAt(5, 100);
if (!b)
cout << "fail" << endl;
b = arr.SetAt(8, 100);
if (!b)
cout << "fail" << endl;
b = arr.SetAt(10, 100);
if (!b)
cout << "fail" << endl;
}
딱 3줄을 제외하고 나머지 줄은 모두 예외 처리
와 관련된 코드다. 코드가 쓸데 없이 길어져서 읽기도 어렵고 이 함수에서 하려고 하는 일이 무엇인지 파악하기도 힘들다. 앞으로 나올 구조적인 예외 처리를 알면 이 함수를 보다 깔끔하게 리팩토링(Refactoring)
할 수 있다.
다음 문제점은 함수가 이미 다른 용도로 반환 값을 사용하는 경우다. 예를 들어 GetAt()
함수는 반환 값을 사용해서 원소의 값을 반환하고 있다. 이 경우 부득이하게 반환 값을 사용해서 예외 처리를 해야 한다면 다음과 같이 함수의 원형을 바꾸는 수 밖에 없다.
bool GetAt(int index, int& value);
반환 값은 예외 상황을 알리는 데 사용하고 두 번째로 있는 레퍼런스 인자를 사용해서 원소의 값을 얻어 오는 것이다. 예외 처리 때문에 함수의 원형을 바꿔야 한다는 점도 문제지만 이렇게 레퍼런스 인자를 사용하는 것도 매운 불편한 일이다. 값을 얻어오기 위해서 매번 int 타입의 변수를 정의한 후에 GetAt()
함수에 넘겨줘야 하기 때문이다. 정리하면 다음과 같다.
이제 구조적 예외 처리를 사용해서 이 문제점들을 해결하는 동시에 보다 효율적으로 예외를 처리할 수 있는 방법도 알아보자.
구조적 예외 처리란 예외 처리의 한 방법이라고 생각하면 된다. 구조적 예외 처리는 이름에서 풍기는 것처럼 보다 계획적이면서 체계가 잡혀있는 예외 처리 방법이다.
일반적으로
C++
이라는 전체 하에서예외 처리
라고 말하면구조적 예외 처리
를 의미한다.
구조적 예외 처리를 위해서 C++
에는 세 가지 키워드를 추가했다. 각각 throw
, try
, catch
이다.
// main.cpp
#include "include/DynamicArray.h"
#include <iostream>
using namespace std;
int main()
{
DynamicArray arr(10);
try
{
arr.SetAt(1, 100);
arr.SetAt(5, 300);
arr.SetAt(18, 500);
}
catch (const char *e)
{
cout << "예외 종류: " << e << endl;
}
catch (const std::out_of_range &e)
{
cout << "예외 종류: " << e.what() << endl;
}
return 0;
}
// DynamicArray.h
#ifndef TEST_DYNAMICARRAY_H
#define TEST_DYNAMICARRAY_H
class DynamicArray
{
public:
DynamicArray(int arraySize);
~DynamicArray();
void SetAt(int index, int value);
int GetAt(int index) const;
int GetSize() const;
protected:
int *arr;
int size;
};
#endif // TEST_DYNAMICARRAY_H
// DynamicArray.cpp
#include "../include/DynamicArray.h"
#include <iostream>
DynamicArray::DynamicArray(int arraySize) : size(arraySize)
{
arr = new int[arraySize];
}
DynamicArray::~DynamicArray()
{
delete[] arr;
arr = nullptr;
}
void DynamicArray::SetAt(int index, int value)
{
if (index < 0 || index >= GetSize())
throw std::out_of_range("out of range.");
arr[index] = value;
}
int DynamicArray::GetAt(int index) const
{
if (index < 0 || index >= GetSize())
throw std::out_of_range("out of range.");
return arr[index];
}
int DynamicArray::GetSize() const
{
return size;
}
결과는 아래와 같다.
예외 종류: out of range.
throw
는 예외를 던지는 명령이다. 예외라고 하면 매우 추상적인 말이지만 여기서는 예외 상황을 알릴 수 있는 값을 의미한다. 예제에서는 std::exception
을 상속받은 클래스인 out_of_range
를 던졌다.
const char*
를 던지지 않고, out_of_range
를 던진 이유는 C++에서 const char*
타입의 예외를 던지는 것을 권장하지 않기 때문이다.
C++
에서 예외는 일반적으로std::exception
을 상속받는 클래스여야 하며, 그렇지 않으면 일관된 예외 처리가 어려워진다.
비표준 예외 타입
std::exception
에서 파생되지 않은 예외를 던지면, 표준적인 방식으로 예외를 처리하기 어렵다.제한된 정보
const char*
는 단순히 문자열 메시지일 뿐, 오류 코드나 예외의 유형 등 추가적인 정보를 포함하지 않는다.try
, catch
는 항상 짝을 이뤄서 사용하는데, catch
키워드부터 살펴보자. catch
키워드와 중괄호로 이루어지는 블록이 바로 예외를 받는 곳이 된다. catch
블럭은 오직 한 가지 타입의 값만 받을 수 있다.
try
는 예외가 던져지는 범위를 지정하는 역할을 한다. 다시 말해 try
블럭안에서 발생하는 예외만 이어지는 catch
블럭에 잡힌다.
예외가 던져지면 DynamicArray::SetAt()
함수는 바로 실행을 종료한다. 그리고 실행의 흐름은 catch
블럭으로 이동한다. 이 때 예외도 전달하는데, 함수에 인자를 전달하는 것과 비슷하게 다음과 같은 가상의 코드를 실행한다고 생각할 수 있다.
const std::out_of_range &e = <던져진 객체>;
맨 처음 예제와 비교하면 가장 중요한 차이점은 이 함수에서 하는 일이 명확하게 보인다는 것이다. 그러기에 소스 코드를 읽기도 수월하다. 예외 처리와 관련된 코드도 잘 분리되어 있어서 지저분하지 않고 그 양도 많이 줄어들었다.
다음으로 SetAt()
, GetAt()
함수는 더 이상 예외 처리를 위해 반환 값을 정의할 필요가 없어졌다.
throw
에 의해서 예외가 던져지면, 그 함수는 바로 종료된다는 사실을 알 수 있다. 또한 throw
에 의해서 던져진 예외는 함수를 뛰어넘어서까지 전달된다는 점도 알 수 있다.
기본 타입 값 대신에 객체
를 예외로써 던지는 것도 가능한데, 이렇게 하는 것은 많은 장점
이 있다.
실제 현장에서도 기본 타입의 값을 예외로 던지는 경우는 거의 없고 대부분
객체
를 던진다.
한 마디로 말하자면 다양한 정보 전달
이다.
객체를 예외로 던지는 것의 첫번째 장점은 다양한 정보
를 전달할 수 있다는 점이다. 객체를 던질 때는 객체의 멤버 변수들이 모두 던져지는 것이므로 필요한 만큼 멤버 변수를 만들어서 사용할 수 있다.
// MyException.h
#ifndef MYEXCEPTION_H
#define MYEXCEPTION_H
#include <exception>
class MyException : public std::exception
{
public:
MyException(const void *sender, const char *description, int info) :
sender(sender), description(description), info(info)
{
}
const void *GetSenderAddr() const
{
return sender;
}
const char *GetDescription() const
{
return description;
}
int GetInfo() const
{
return info;
}
protected:
const void *sender;
const char *description;
int info;
};
#endif // MYEXCEPTION_H
// DynamicArray.h
#ifndef DYNAMICARRAY_H
#define DYNAMICARRAY_H
class DynamicArray
{
public:
DynamicArray(const int);
~DynamicArray();
int GetSize() const;
void SetAt(const int, const int);
int GetAt(const int) const;
private:
int *arr;
int size;
};
// DynamicArray.cpp
#include "DynamicArray.h"
#include "MyException.h"
DynamicArray::DynamicArray(const int size) : size(size)
{
arr = new int[size];
}
DynamicArray::~DynamicArray()
{
delete[] arr;
arr = nullptr;
size = 0;
}
int DynamicArray::GetSize() const
{
return size;
}
void DynamicArray::SetAt(const int index, const int value)
{
if (index < 0 || index >= GetSize())
// 생성자를 사용해 임시 객체 생성 후 던진다.
throw MyException(this, "out of range!", index);
arr[index] = value;
}
int DynamicArray::GetAt(const int index) const
{
if (index < 0 || index >= GetSize())
throw MyException(this, "out of range!", index);
return arr[index];
}
// main.cpp
#include "DynamicArray.h"
#include "MyException.h"
#include <iostream>
using namespace std;
void UseArray(DynamicArray &arr1, DynamicArray &arr2);
int main()
{
// 배열 객체 2개 생성
DynamicArray arr1(10);
DynamicArray arr2(8);
UseArray(arr1, arr2);
return 0;
}
void UseArray(DynamicArray &arr1, DynamicArray &arr2)
{
try
{
arr1.SetAt(5, 100);
arr2.SetAt(5, 100);
arr1.SetAt(8, 100);
arr2.SetAt(8, 100);
arr1.SetAt(10, 100);
arr2.SetAt(10, 100);
}
catch (const MyException &e)
{
// 두 배열의 주소 출력
cout << "&arr1 = " << &arr1 << endl;
cout << "&arr2 = " << &arr2 << endl;
cout << "instance address: " << e.GetSenderAddr() << endl;
cout << "description: " << e.GetDescription() << endl;
cout << "information: " << e.GetInfo() << endl;
}
}
결과는 다음과 같다.
&arr1 = 0xdd8abff710
&arr2 = 0xdd8abff700
instance address: 0xdd8abff700
description: out of range!
information: 8
MyException
클래스의 정의를 보면, 예외를 발생시킨 객체의 주소와 예외를 설명하는 문자열 그리고 부가 정보를 보관할 수 있게 해두었다.
MyException
객체를 예외로 던지면, 이 정보들이 모두 던져지는 셈이다. 그렇기 때문에 예외를 받은 곳에서는 이 정보들을 사용해서 보다 구체적으로 예외를 파악할 수 있다.
UsingArray()
함수의 catch
블럭에서는 arr1
과 arr2
의 주소를 출력한 다음에 예외 객체의 멤버 변수들도 출력한다. 여기에는 예외를 던진 객체의 주소
도 포함되어 있기 때문에 arr1
과 arr2
중에서 어떤 객체가 예외를 던졌는지 알 수 있다. 또한 예외 객체의 info 멤버에는 예외가 발생할 당시의 인덱스가 담겨있기 때문에 try
블럭 안의 어떤 코드에서 예외가 발생했는지도 알 수 있다.
객체를 예외로 던질 때도 다형성 (Polymorphism)
을 사용할 수 있다. 다음 예제에는 MyException
클래스를 상속 받은 2개의 예외 클래스를 추가했다.
// MyException.h
// 인덱스와 관련된 예외
class OutOfRangeException : public MyException
{
public:
OutOfRangeException(const void *sender, const int index) : MyException(sender, "out of range!", index)
{
}
};
// 메모리와 관련된 예외
class MemoryException : public MyException
{
public:
MemoryException(const void *sender, const int bytes) : MyException(sender, "out of memory!", bytes)
{
}
};
// DynamicArray.cpp
void DynamicArray::SetAt(const int index, const int value)
{
if (index < 0 || index >= GetSize())
throw OutOfRangeException(this, index);
arr[index] = value;
}
int DynamicArray::GetAt(const int index) const
{
if (index < 0 || index >= GetSize())
throw OutOfRangeException(this, index);
return arr[index];
}
// main.cpp
... 생략
void UseMemory();
int main()
{
// 배열 객체 2개 생성
DynamicArray arr1(10);
DynamicArray arr2(8);
UseArray(arr1, arr2);
return 0;
}
void UseMemory()
{
// 1000바이트를 할당하려다 실패했다고 가정
throw MemoryException(nullptr, 1000);
}
void UseArray(DynamicArray &arr1, DynamicArray &arr2)
{
try
{
arr1.SetAt(5, 100);
arr2.SetAt(5, 100);
UseMemory();
arr1.SetAt(8, 100);
arr2.SetAt(8, 100);
arr1.SetAt(10, 100);
arr2.SetAt(10, 100);
}
// MyException 예외를 받는다.
// 객체를 예외로 받을 때는 레퍼런스 타입으로 받는 것이 좋다.
catch (const MyException &e)
{
cout << "description: " << e.GetDescription() << endl;
}
}
catch
블럭에서 MyException &
타입의 예외를 받을 수 있게 되어 있다. 이렇게 하면 MyException
, OutOfRangeException
, MemoryException
객체 모두를 받을 수 있다. 자식 객체를 부모 클래스 타입의 레퍼런스로 가리킬 수 있기 때문이다. 다형성
의 편리함을 다시 한번 느끼는 부분이다.
예외는 함수를 여러 개 건너서도 전달할 수 있다.
#include <iostream>
using namespace std;
void A();
void B();
void C();
int main()
{
try
{
A();
}
catch (const int e)
{
cout << "except: " << e << endl;
}
return 0;
}
void A()
{
cout << "begin, A()" << endl;
B();
cout << "end, A()" << endl;
}
void B()
{
cout << "begin, B()" << endl;
C();
cout << "end, B()" << endl;
}
void C()
{
cout << "begin, C()" << endl;
throw 337;
cout << "end, C()" << endl;
}
결과는 다음과 같다.
begin, A()
begin, B()
begin, C()
except: 337
위의 예제를 보면 3개의 함수를 건너 뛰어서 예외를 전달하는 것을 확인할 수 있다.
main()
-> A()
-> B()
-> C()
의 순서로 함수를 호출했다. 그리고 C()
에서는 정수 값을 예외로 던진다.
이 예외가 던져진 순간 C()
함수가 종료
된다. 이어서 B()
, A()
함수도 차례로 종료
된다. 그리고 main()
함수의 catch
블럭으로 실행 흐름이 이동된다.
이렇게 예외는 자신의 타입에 맞는 catch
블럭을 찾을 때까지 함수를 거슬러 올라간다.
그런데 만약 main()
함수까지 갔는데도 알맞은 catch
블럭을 찾을 수 없다면 어떻게 될까?
이 경우에는 프로그램이 비정상 종료
되어버린다. main()
함수에서는 반드시 모든 예외를 잡아주어야 한다. 그 예외가 예상할 수 있는 것이 아니라 할지라도 프로그램이 비정상 종료
하게 내버려 둘 수는 없기 때문이다.
void A() {
try {
B();
}
catch (char c)
{
cout << "A() 함수에서 잡은 예외: " << c << endl;
// 받을 예외를 그대로 다시 던진다. 또 다시 함수를 거슬러 올라가면서 알맞은 catch 블럭을 찾게 된다.
throw;
}
}
catch
블럭 안에서throw
라고만 적어주면 받을 예외를 다시 던지게 된다.
일반적으로 예외를 다시 던지는 이유는 이 예외를 자신이 받아서 처리했지만 외부에도 예외 상황을 알릴 필요가 있기 때문에다.
하나의 try
블럭에는 여러 개의 catch
블럭이 따라올 수 있다. 그리고 각 catch
블럭은 서로 다른 타입의 예외를 받게 된다.
#include <iostream>
#include "MyException.h"
using namespace std;
void A();
int main()
{
try
{
A();
}
catch (const MemoryException &e)
{
cout << "catch, MemoryException" << endl;
}
catch (const OutOfRangeException &e)
{
cout << "catch, OutOfRangeException" << endl;
}
catch (...)
{
// 그 밖의 타입
cout << "other errors..." << endl;
}
return 0;
}
void A()
{
throw OutOfRangeException(nullptr, 0);
}
결과는 다음과 같다.
catch, OutOfRangeException
마지막 catch (...)
는 모든 타입
의 예외
를 받는다. 앞의 두 catch
문 모두 예외를 받을 수 없는 경우에는 이 catch
블럭이 실행된다.
이 catch
블럭이 반드시 있어야 하는 것은 아니지만 나머지 모든 예외를 받고 싶은 경우에 매우 유용
하게 사용할 수 있다.
try
블럭 안에서 예외가 던져지면 제일 앞에 있는 catch
블럭부터 시작해서 예외를 받을 수 있는지 비교
한다. 만약 타입이 일치한다면 해당 catch
블럭을 실행
한다.
// 잘못된 catch 블럭
catch(MyException &e) {
cout << "MyException catch!" << endl;
}
catch(OutOfRangeException &e) {
cout << "OutOfRangeException catch!" << endl;
}
catch(...) {
cout << "그 밖의 모든 예외" << endl;
}
만약 위와 같이 작성했다면 OutOfRangeException
catch
블럭은 영원히 실행될 수 없다. 다형성
때문에 OutOfRangeException
객체는 부모 타입인 MyException
처럼 다룰 수 있기 때문이다.
또 다른 예로 catch(...)
블럭이 맨 앞에 오는 경우도 마찬가지다. 이 블럭이 모든 예외를 받을 것이기 때문에 뒤에 오는 어떤 catch
블럭도 예외를 받을 수 있다.
객체를 예외로 던지고 받을 때는 크게 세 가지 경우를 생각해볼 수 있는데 각각 객체
, 포인터
, 레퍼런스
를 사용하는 경우다. 이 중에서 레퍼런스
를 사용하는 것이 가장 바람직하다. 왜 그런지 알아보자.
try {
MyException e(this, "test", 0);
throw e;
}
// 던져진 객체가 e에 복사되면서 복사 생성자 호출
catch(MyException e) {
...
}
위의 경우의 문제점은 불필요한 복사
가 발생
한다는 것이다. catch
블럭이 시작되면서 다음과 같은 가상의 코드를 실행한다고 생각할 수 있는데, 이 때 MyException
클래스의 복사 생성자가 실행되면서 멤버들을 1:1로 복사
한다.
MyException e = e;
반면에 포인터
나 레퍼런스
를 사용하는 경우에는 불필요한 복사가 발생하지 않는다. 이번에는 포인터
를 던지고 받는 경우를 보자.
try {
MyException *p = new MyException(this, "test", 0);
throw p;
}
catch(MyException *pe) {
cout << pe->GetDescription() << endl;
delete pe;
}
포인터
를 던지고 받을 때는 불필요한 복사가 일어나지 않는다. 하지만 메모리
할당과 해제를 신경 써야 하는 단점
이 있다.
try {
MyException e(this, "test", 0);
throw e;
}
catch(MyException &e) {
cout << e.GetDescription() << endl;
}
이 경우에는 catch
블럭의 시작에서 다음과 같은 가상의 코드
가 실행된다고 생각해볼 수 있다.
MyException &e = e;
레퍼런스를 사용한 경우에는 불필요한 복사가 발생하지 않는다. 또한 메모리 관리를 신경쓸 필요도 없다. 그래서 객체
를 던지고 레퍼런스
로 받는 방법이 제일 유용
하다.
📌 예외를 받을 때는, 되도록
레퍼런스
를 사용하자!
C++
에서는 많은 예외 클래스들을 제공하는데, 그 클래스들은 모두 exception
클래스를 부모
로 가지고 있다.
클래스 이름 | 요약 | 포함된 파일 |
---|---|---|
exception | 최상위 예외 클래스 | <exception> |
bad_alloc | new 연산자가 메모리 할당에 실패했을 경우 | <new> |
bad_cast | dynamic_cast 연산자가 형 변환에 실패했을 경우 | <typeinfo> |
invalid_argument | 잘못된 인자를 입력한 경우 | <stdexcept> |
length_error | 제한된 길이를 넘어섰을 경우. 예를 들어 10개의 노드만 가질 수 있게 되어 있는 링크드-리스트에 새 노드를 추가하려고할 경우 | <stdexcept> |
overflow_error | 오버플러우가 발생한 경우 | <stdexcept> |
range_error | 정해진 범위를 넘어섰을 경우. 예를 들어 원소가 10개인 배열에게 11번째 원소를 요구한 경우 | <stdexcept> |
이 클래스들을 직접 사용하거나 이 클래스들을 상속
받아 사용할 수 있다. 이 클래스들을 사용할 때는 표의 가장 오른쪽 열에 나오는 헤더 파일
을 포함(include)
시켜야 한다.
#include <exception>
#include <string>
class MyException : public exception {
public:
MyException(const string& msg) : _str(msg) { }
virtual ~MyException() { }
virtual const char* what() const override {
return _str.c_str();
}
protected:
string _str;
};