Exception

Date:     Updated:

카테고리:

태그:

모두의 코드 씹어먹는 c++ 자료를 보고 정리한 내용입니다.


기존 C 에서는 언어 차원에서 예외 처리 방식을 따로 제공하지 않았다. 따라서 아래와 같이 어떤 작업을 실행한 뒤에, 그 결과값을 확인하는 방식으로 처리했다. 하지만 이러한 방식으로 예외를 처리한다면, 함수가 깊어질 수록(함수 내부에서 함수를 호출) 복잡해진다.

char *c = (char *)malloc(1000000000);
if (c == NULL) {
  printf("메모리 할당 오류!");
  return;
}

예외 발생시키기 Throw

c++ 에서는 throw문을 이용해 예외가 발생했다는 사실을 명시적으로 나타낼 수 있다.

template <typename T>
class Vector {
 public:
  Vector(size_t size) : size_(size) {
    data_ = new T[size_];
    for (int i = 0; i < size_; i++) {
      data_[i] = 3;
    }
  }
  const T& at(size_t index) const {
    if (index >= size_) {
      throw out_of_range("vector 의 index 가 범위를 초과하였습니다.");
    }
    return data_[index];
  }
  ~Vector() { delete[] data_; }

 private:
  T* data_;
  size_t size_;
};

C 언어에서는 예외가 발생했을 때, 다른 값을 리턴하는 방식으로 예외를 처리했다. 위 vector 클래스에서 at 함수의 문제는 const T&를 리턴하기 때문에 따로 오류 메시지를 리턴할 수 없다는 점이다.

하지만 c++ 에서는 예외를 던지고 싶다면, throw로 전달하고 싶은 예외 객체를 써주면 된다. 예외로 아무 객체나 던져도 상관은 없지만, c++ 표준 라이브러리에는 이미 여러가지 종류의 예외들이 정의되어 있어서 이를 활용하는 것이 좋다. 예를 들어 out_of_range 외에도 overflow_error, length_error, runtime_error 등등이 있다.

이렇게 예외를 던지면, throw한 위치에서 즉시 함수가 종료되고, 예외를 처리하는 부분(catch)까지 점프를 하게 된다. 따라서 throw 밑에 있는 문장들은 실행되지 않는다. 여기서 중요한 점은 함수를 빠져나가면서 stack에 생성되었던 객체들을 빠짐없이 소멸시켜 준다는 것이다(소멸자만 제대로 작성했다면).

예외 처리하기 Try & Catch

Vector<int> vec(3);

int index, data = 0;
std::cin >> index;

try {
data = vec.at(index);
} catch (std::out_of_range& e) {
std::cout << "예외 발생 ! " << e.what() << std::endl;
}
// 예외가 발생하지 않았다면 3을 이 출력되고, 예외가 발생하였다면 원래 data 에
// 들어가 있던 0 이 출력된다.
std::cout << "읽은 데이터 : " << data << std::endl;

try 문 안에서는 무언가 예외가 발생할만한 코드가 실행된다. 만약 예외가 발생하지 않았다면 try ... catch 부분이 없는 것과 동일하게 실행 된다. 반면에 예외가 발생하게 되면, 그 즉시 stack에 생성된 모든 객체들의 소멸자가 호출되고, 가장 가까운 catch 문으로 점프한다(throw를 호출한 함수를 빠져 나가면서 가장 먼저 만나는 catch 문으로).

catch 문은 throw 된 예외를 받는 부분인데, catch 문 안에 정의된 예외에 맞는 꼴의 객체를 받게 된다. 위 코드의 경우 Vectorout_of_range 클래스를 throw 하고, catch 문에서 out_of_range를 받게 된다. 해당 예외 객체의 내용은 what() 함수를 통해 들여다 볼 수 있다.

스택 풀기 (Stack Unwinding)

class Resource {
 public:
  Resource(int id) : id_(id) {}
  ~Resource() { std::cout << "리소스 해제 : " << id_ << std::endl; }

 private:
  int id_;
};

int func3() {
  Resource r(3);
  return 0;
}
int func2() {
  Resource r(2);
  func3();
  std::cout << "실행!" << std::endl;
  return 0;
}
int func1() {
  Resource r(1);
  func2();
  std::cout << "실행!" << std::endl;
  return 0;
}

int main() {
  try {
    func1();
  } catch (std::exception& e) {
    std::cout << "Exception : " << e.what();
  }
}

[!result] 리소스 해제 : 3 실행! 리소스 해제 : 2 실행! 리소스 해제 : 1

일반적인 상황에서는 객체의 소멸자들은 함수가 종료될 때 호출되므로 실행! 이 먼저 출력되고, 그 뒤에 리소스 해제 메시지가 출력된다.


class Resource {
 public:
  Resource(int id) : id_(id) {}
  ~Resource() { std::cout << "리소스 해제 : " << id_ << std::endl; }

 private:
  int id_;
};

int func3() {
  Resource r(3);
  throw std::runtime_error("Exception from 3!\n");
}
int func2() {
  Resource r(2);
  func3();
  std::cout << "실행 안됨!" << std::endl;
  return 0;
}
int func1() {
  Resource r(1);
  func2();
  std::cout << "실행 안됨!" << std::endl;
  return 0;
}

int main() {
  try {
    func1();
  } catch (std::exception& e) {
    std::cout << "Exception : " << e.what();
  }
}

[!result] 리소스 해제 : 3 리소스 해제 : 2 리소스 해제 : 1 Exception : Exception from 3!

반면 예외가 전파되는 과정에서는 바로 catch 부분으로 점프 하면서, 각 함수들에 있던 객체들만 해제하기 때문에 리소스 해제 메시지는 정상적으로 출력되지만, 그 뒤 실행 안됨! 부분은 출력되지 않는다.

이와 같이 catch 문으로 점프 하면서 스택 상에 정의된 객체들을 소멸시키는 과정을 스택 풀기(Stack Unwinding) 이라 한다.

[!danger] 예외를 생성자에서 던질 때에는 주의해야 할 점이 하나 있다. 바로 생성자에서 예외가 발생 시에 소멸자가 호출되지 않는다는 점이다. 따라서 만약 예외를 던지기 이전에 획득한 자원이 있다면 catch 문에서 해당 자원을 해제시 줘야 한다.

여러 종류의 예외 받기

int func(int c) {
  if (c == 1) {
    throw 10;
  } else if (c == 2) {
    throw std::string("hi!");
  } else if (c == 3) {
    throw 'a';
  } else if (c == 4) {
    throw "hello!";
  }
  return 0;
}

int main() {
  int c;
  std::cin >> c;

  try {
    func(c);
  } catch (char x) {
    std::cout << "Char : " << x << std::endl;
  } catch (int x) {
    std::cout << "Int : " << x << std::endl;
  } catch (std::string& s) {
    std::cout << "String : " << s << std::endl;
  } catch (const char* s) {
    std::cout << "String Literal : " << s << std::endl;
  }
}

한 개의 try 안에 받고자 하는 예외들을 catch 문으로 주렁주렁 달면 된다.


부모 클래스와 상속 클래스의 경우 예외를 처리할 때 주의가 필요하다.

class Parent : public std::exception {
 public:
  virtual const char* what() const noexcept override { return "Parent!\n"; }
};

class Child : public Parent {
 public:
  const char* what() const noexcept override { return "Child!\n"; }
};

int func(int c) {
  if (c == 1) {
    throw Parent();
  } else if (c == 2) {
    throw Child();
  }
  return 0;
}

int main() {
  int c;
  std::cin >> c;

  try {
    func(c);
  } catch (Parent& p) {
    std::cout << "Parent Catch!" << std::endl;
    std::cout << p.what();
  } catch (Child& c) {
    std::cout << "Child Catch!" << std::endl;
    std::cout << c.what();
  }
}

[!result] 1 Parent Catch! Parent! … 2 Parent Catch! Child!

Parent 클래스 객체를 throw 한 경우에는 예상했던데로 작동한 반면, Child 객체를 throw 한 경우 Child를 받는 catch 문이 아닌, Parent를 받는 catch 문이 실행되어 버린다.

이와 같은 일이 발생한 이유는, catch 문의 경우 가장 위에서 부터 차례로 대입될 수 있는 객체를 받는데

Parent& p = Child();

가 가능하기 때문에 Parentcatch 가 먼저 받아버리는 것이다.

위와 같은 문제를 방지하기 위해서는 언제나 부모 클래스의 catch 문을 자식 클래스의 catch 문 보다 뒤에 써주는 것이 좋다.

[!tip] 일반적으로 예외 객체는 std::exception 을 상속 받는 것이 좋다. 그래야 표준 라이브러리의 유용한 함수들( nested_exception 등)을 사용할 수 있기 때문이다.

모든 예외 받기

만약 어떤 예외를 throw 했는데, 해당 예외를 받는 catch 가 없다면 어떻게 될까? 이런 경우 런타임 에러 예외를 발생시키며 프로그램이 종료된다. 따라서 예외를 던지는 코드가 있다면 이를 적절하게 받아내는 것이 중요하다. 이런 경우를 위해, 예외 객체들을 하나 하나 처리할 필요 없이 그냥 나머지 전부를 처리해주는 문법이 존재한다.

try {
func(c);
} catch (int e) {
std::cout << "Catch int : " << e << std::endl;
} catch (...) {
std::cout << "Default Catch!" << std::endl;
}

위 코드와 같이 마지막 catch (...) 에서 나머지 모든 예외들을 받게 된다. 어떠한 예외도 다 받아야 하기 때문에 당연히 특정 타입을 찍어서 객체에 대입 시킬 수 없다.

[!danger] 템플릿으로 정의되는 클래스의 경우 어떤 타입으로 인스턴스화 되었냐에 따라 던지는 예외의 종류가 달라질 수 있다. 이 때문에 템플릿 클래스의 경우 모든 예외 객체를 반드시 고려해야 한다.

예외를 발생시키지 않는 함수 noexcept

int foo() noexcept {}

만약 어떤 함수가 예외를 발생시키지 않는다면 noexcept 를 통해 이를 명시할 수 있다. 참고로, noexept 키워드를 붙였다고 해서, 절대로 예외를 던지지 않는 것은 아니다. 실제로 예외를 발생시켜도 경고 문구만 뜨고 컴파일은 가능하다. 대신 noexcept 로 명시된 함수가 예외를 발생시키게 된다면 예외가 제대로 처리되지 않고 프로그램이 종료된다.

간단히 말하자면 noexcept 키워드를 븥이는 이유는 프로그래머가 컴파일러에게 주는 힌트라고 볼 수 있다. 어떤 함수가 절대로 예외를 발생시키지 않는다면, 컴파일러가 추가적인 최적화를 수행할 수 있기 때문이다.

참고로 c++ 11 부터 소멸자들은 기본적으로 noexcept이다. 따라서 절대로 소멸자에서 예외를 던지면 안된다.



맨 위로 이동하기

Cpp 카테고리 내 다른 글 보러가기

댓글 남기기