CS/C++

C++ 공부 섹션14 예외처리 : 홍정모의 따배씨쁠쁠

샤아이인 2022. 1. 17.

내돈내고 내가 공부한것을 올리며, 중요한 단원은 저 자신도 곱씹어 볼겸 상세히 기록하고 얕은부분들은 가겹게 포스팅 하겠습니다.

1) 섹션14

이번시간에는 예외처리 에 대하여 배웠다. 아직 1년정도밖에 안된 나도 고수들의 코드를 보면 항상 느낀점이 예외의 경우를 상당량 고려한 코드를 작성함을 느낀적이 많다. 예외처리는 진짜 숙련된 만큼 처리가 가능한 것 같다(?).

14-1 예외처리의 기본

전통적으로 프로그램이 정상적으로 기능을하는지 확인하는 방식 에 대하여 우선 알아보자.

#include <iostream>
#include <fstream>
#include <string>

using namespace std;

int findFirstChar(const char* string, char ch) {
	for (size_t index = 0; index < strlen(string); index++) {
		if (string[index] == ch)
			return index;
	}
	return -1; // no match
}

double divide(int x, int y, bool& success) {
	if (y == 0)
	{
		success = false;
		return 0.0;
	}

	success = true;
	return static_cast<double>(x) / y;
}

int main()
{
	bool success;
	double result = divide(5, 3, success);

	if (!success)
		cout << "An error occurred" << endl;
	else
		cout << "Result is " << result << endl;

	ifstream input_file("temp.txt"); // input파일 초기화 시도
	if (!input_file)
		cerr << "Cannot open file" << endl;

	return 0;
}  
 

findFirstChar 의 경우 input으로 들어간 글자를 찾아 index를 반환해주는 함수이다.

해당 글자를 찾을경우 정상적으로 index를 알려주지만, 실패시 -1값을 반환해 준다.

이러한 구분으로 오류가 발생했음을 알 수 있지만, 해당 사용자가 -1이 오류의 지표임을 사전에 알고있어야 한다는 단점이 있다.

 

- divide 의 경우 x를 y로 나누는 함수이다. 분모는 0이될수 없다. 따라서 y가 0인경우 예외처리를 해줘야 한다.

이후 success 참조 변수에 bool값을 전달하여 외부에서 확인할 수 있고, 결과값으로 double을 반환하는 함수이다.

 

전통적인 C++에서는 위와같은 방식으로 코딩을 해왔다고 하셨다. 그 이유는 다음과 같다.

1) 퍼포먼스

2) 대체문법이 마땅하지 않음

 

지금부터 볼 예외처리는 관점이 약간 다르다. 또한 예외처리를 하면 약간 느려지는 경향이 있다.

예외처리는 정말로 예측할수없는 일이 빈번하게 발생하는경우에 주로 사용한다 하셨다.

예를들어 게임서버가 예측할수없는 일이 많이 발생하는경우에도 서버는 계속 버티며 작업을 지속하기위해서 사용하는경우가 많다 하셨다.

 

예외처리는 try, catch, throw로 구성된다. try를 통하여 시도를 했더니 정상적으로 작동한다면 계속 쭉 작동할 것 이다.

하지만 만약 오류가 발생한다면 throw를 하며, "예외를 던진다" 라고 표현한다. 

catch의 경우 던져진 에러를 받아서 처리하는 관점이다.

#include <iostream>
#include <string>

using namespace std;

int main()
{
	double x;
	cin >> x;

	try
	{ // 문제가 생길 여지가 있는부분
		if (x < 0.0) throw string("Negative input");

		cout << sqrt(x) << endl;
	}
	catch (string error_message)
	{
		cout << error_message << endl;
	}

	return 0;
}      
 

위의 코드를 보면 main안에서 x를 입력받아 sqrt()에 인자로 넘겨주고있다. square root는 0보다 작을수가 없기때문에 0보다 작은값을 인자로 넘겨줄려하는 경우에대하여 try문안에 삽입하여 문제가 생길 여지가 있는 부분을 대비한다.

 

만약 0보다 작은수가 들어온다면 throw를 통하여 string이 catch를 향해 문자열을 날려준다.

그러면 catch하는 쪽에서 std::string으로 에러메세지를 잡는다. 이후 대응할수있는 행위를 할 것 이다. 실행결과를 확인해보자.

또한 예외처리에서는 형변환을 매우엄격하게 다루기때문에 위의 코드에서 "Negative input"를 string으로 넘겨주지않으면 throw를 한 문자열을 받을곳이 없어 runtime error가 발생한다.

따라서 다음과 같은경우도 에러가나타난다. 확인해보자.

위의 코드와 같이 throw로 -1.0(double)형을 넘겨주었다. 하지만 catch는 int형만 받기때문에 runtime_error이 발생하다.

자동casting은 일어나지 않으며, 타입에 대하여 엄격히 작용한다.

 

14-2 예외처리와 스택 되감기 (Stack Unwinding)

함수가 함수를 호출하고, 그 호출된 함수가 다시 또 다른 함수를 호출한다면 stack에 호출구조가 쌓이게 된다.

그럼 만약 가장 안쪽에 있는 함수가 예외를 던진다면 stack을 되감아 가면서 어디서 예외를 받을지 찾게된다.

#include <iostream>
#include <string>

using namespace std;

void last() {
	cout << "last" << endl;
	cout << "Throws exception" << endl;

	throw - 1;

	cout << "End last " << endl; // 실행안됨
}

void third() {
	cout << "Third" << endl;
	last();
	cout << "End third" << endl; // 실행안됨
}

void second() {
	cout << "Second" << endl;

	try
	{
		third();
	}
	catch (double)
	{
		cerr << "Second caught double exception" << endl;
	}

	cout << "End second" << endl; // 실행안됨
}

void first(){
	cout << "first" << endl;

	try 
	{
		second();
	}
	catch (int)
	{
		cerr << "first caught int exception" << endl;
	}

	cout << "End first" << endl; // 정상작동 
}

int main()
{
	cout << "Start" << endl;

	try
	{
		first();
	}
	catch (int)
	{
		// std::cerror
		cerr << "main caught int exception" << endl;
	}

	cout << "End main" << endl;

	return 0;
}            
 

위의 코드를 실행해보면 main() -> try first() -> try second() -> try third() -> last() -> "Throws exception" -> throw -1 -> 바로 first의 catch(int)로 날라옴

 

어떻게 돌아가는 것 일까?? 우선 last()에서 throw -1을 하였기 때문에 cout << "End last " << endl; 은 출력되지 않았다.

이후 thrid()로 온후 last에서 에러를 잡아주는곳이 없기때문에 바로 stack에서 unwinding을 한 것 이다.

이후 다시 second로 거슬러 올라간다. 이번에는 try catch가 있다. 하지만 문제는 catch가 double밖에 없다.

따라서 다시 거슬러 올라가(unwinding)서 first함수로 돌아온다.

여기에 catch(int)가 있다. 따라서 여기서 catch문이 실행되고 에러를 잡는다. 이후 first함수의 cout << "End first" << endl; 를 실행한 후 다시 정상적으로 main으로 돌아와 실행된다.

 

또한 위의 코드에서는 문자형에 대해서는 throw를 받아줄 catch가 없는상황이다.

또한 모든 경우를 다 대비하지 못할수도 있기때문에 모든 경우를 다 잡아주는 ellipses를 사용할 수 있다.

다음 코드를 살펴보자.

void last() {
	cout << "last" << endl;
	cout << "Throws exception" << endl;

	throw 'a'; // 문자열 throw

	cout << "End last " << endl; // 실행안됨
}

... 중략 ...

int main()
{
	cout << "Start" << endl;

	try
	{
		first();
	}
	catch (int)
	{
		// std::cerror
		cerr << "main caught int exception" << endl;
	}
	catch (...) // catch-all handlers
	{
		cerr << "main caught ellipses exception" << endl;
	}

	cout << "End main" << endl;

	return 0;
} 
 

catch(..)안에서 괄호안에 ellipses가 있다. 이경우에는 어떤타입이든 다 받는다. 위에서말한 문자형도 여기서 받아줄 것 이다.

 

추가적으로 exception epecifier에 대하여 배웠다.

void last() throw(int) {
	cout << "last" << endl;
	cout << "Throws exception" << endl;

	throw 'a';

	cout << "End last " << endl; // 실행안됨
}
 

위의 경우 함수이름 뒤에 throw(int)가 추가되었다. 이경우 "int타입을 throw할수도 있다." 라는 의미이다.

추가적으로 비쥬얼스튜디오 문서를 찾아보면 타입을 어떤것을 넣어주든 ellipses라 생각한다. 꼭 int가 온다는것이 아니라, 예외를 던질 수 있는 가능성이 있는 함수이다 라고 생각하면 된다.

하지만 parameter가 없는경우에는 경고가 나타난다. parameter가 없는경우에는 예외를 안던질것이라고 말하는것과 같다.

 

 

14-3 예외 클래스와 상속

기본자료형이 아닌 사용자정의 자료형, 예외 class를 만드는법에 대하여 배웠다. 또한 예외 class에 상속을 사용할때 주의해야할점 또한 배웠다.

#include <iostream>
#include <string>

using namespace std;

class MyArray {
private:
	int m_data[5];
public:
	int& operator [] (const int& index)
	{ // member funtion에서도 예외를 throw가능
		if (index < 0 || index >= 5) throw - 1;

		return m_data[index];
	}
};

void doSomething()
{
	MyArray my_array;

	try
	{
		my_array[100]; // index는 0 ~ 4 까지만 가능
	}
	catch (const int& x)
	{
		cerr << "Exception " << x << endl;
	}
}

int main()
{
	doSomething();

	return 0;
}            
 

위의 코드에서처럼 기본 자료형만으로 예외를 던진다면 표현하지못하는 것 이 많을것이다. 이럴때 직접 예외 class를 만들수 있다.

기본자료형이 들어오듯, 우리가 만든 class자료형이 들어오면 된다. 다음 코드를 살펴보자.

#include <iostream>
#include <string>

using namespace std;

class Exception {
public:
	void report()
	{
		cerr << "Exception report" << endl;
	}
};

class MyArray {
private:
	int m_data[5];
public:
	int& operator [] (const int& index)
	{ // member funtion에서도 예외를 throw가능
		// if (index < 0 || index >= 5) throw - 1;
		if (index < 0 || index >= 5) throw Exception(); // 객체를 하나 던짐

		return m_data[index];
	}
};

void doSomething()
{
	MyArray my_array;

	try
	{
		my_array[100]; // index는 0 ~ 4 까지만 가능
	}
	catch (const int& x)
	{
		cerr << "Exception " << x << endl;
	}
	catch (Exception& e) // 객체를 받음
	{
		e.report();
	}
}

int main()
{
	doSomething();

	return 0;
}            
 

이전코드에서 Exception class가 추가되었다. 또한 throw로 Exception의 객체를 하나 던지고 있다. 실행결과를 살펴보자.

catch를 참조형 변수로 받은후 class내부의 멤버함수를 실행하고있는 것 이다. 다음으로 조금 주의가 필요한 경우는 상속의 경우이다.

다음 코드는 Exception class를 상속받는 ArrayException의 경우를 살펴보자.

class Exception {
public:
	void report()
	{
		cerr << "Exception report" << endl;
	}
};

class ArrayException : public Exception
{
public:
	void report()
	{
		cerr << "Array exception" << endl;
	}
};

class MyArray {
private:
	int m_data[5];
public:
	int& operator [] (const int& index)
	{ // member funtion에서도 예외를 throw가능
		// if (index < 0 || index >= 5) throw - 1;
		if (index < 0 || index >= 5) throw ArrayException(); // 객체를 하나 던짐

		return m_data[index];
	}
};

void doSomething()
{
	MyArray my_array;

	try
	{
		my_array[100]; // index는 0 ~ 4 까지만 가능
	}
	catch (const int& x)
	{
		cerr << "Exception " << x << endl;
	}
	catch (Exception& e) // 객체를 받음
	{
		e.report();
	}
}

int main()
{
	doSomething();

	return 0;
}    
 

ArrayException 객체 하나를 throw하였는데 이를 catch(Exception & e)로 받으면 다음과 같은 결과가 나온다.

받아주기는 받아주는데 Exception의 report()가 실행된 것 이다. 그럼 간단히 다음을 추가해주면 어떻게될까?

	catch (Exception& e) // 객체를 받음
	{
		e.report();
	}	
    catch (ArrayException& e)  // 추가된 부분
	{
		e.report();
	}
 

실행결과는 놀랍게도 이전과 같은 Exception report라고 출력된다. 에러창을 보면 다음과 같다.

이말은 이미 Exception & e 에서 잡혔다는 말이다. 부모 class가 먼저 catch를 해버리니까 자식class에서 catch를 할수없는것 이다.

이럴때는 어떻게 해야할까? 간단히 자식 class가 먼저오도록 순서를 바꾸면 된다.

	catch (ArrayException& e)
	{
		e.report();
	}
	catch (Exception& e) // 객체를 받음
	{
		e.report();
	}
 

이번에는 이전 단원에서 배운 stack unwinding과 연관지어 다시 생각해보자.

#include <iostream>
#include <string>

using namespace std;

class Exception {
public:
	void report()
	{
		cerr << "Exception report" << endl;
	}
};

class ArrayException : public Exception
{
public:
	void report()
	{
		cerr << "Array exception" << endl;
	}
};

class MyArray {
private:
	int m_data[5];
public:
	int& operator [] (const int& index)
	{ // member funtion에서도 예외를 throw가능
		// if (index < 0 || index >= 5) throw - 1;
		if (index < 0 || index >= 5) throw ArrayException(); // 객체를 하나 던짐

		return m_data[index];
	}
};

void doSomething()
{
	MyArray my_array;

	try
	{
		my_array[100]; // index는 0 ~ 4 까지만 가능
	}
	catch (const int& x)
	{
		cerr << "Exception " << x << endl;
	}
	catch (ArrayException& e)
	{
		cout << "doSomething()" << endl;
		e.report();
	}
	catch (Exception& e) // 객체를 받음
	{
		cout << "doSomething()" << endl;
		e.report();
	}
}

int main()
{
	try
	{
		doSomething();
	}
	catch (ArrayException& e)
	{
		cout << "main()" << endl;
		e.report();
	}

	return 0;
}            
 

main에서 try로 doSomething()을 실행한 후 doSomething내부에서 발생한 오류를 doSomething함수 내부의 catch (ArrayException& e) 로 받아서 처리하고있다.

결과를 확인해보자.

main내부의 catch (ArrayException& e)는 실행되지 않고있다. 만약 main에서도 처리하고싶다면, catch한 것을 다시 throw하면 된다.

	catch (ArrayException& e)
	{
		cout << "doSomething()" << endl;
		e.report();
		throw e; // 다시 throw
	}
 

위의 코드를 보면 다시 throw하고있는것을 볼 수 있다. 이렇게 되면 main에서도 Array exception을 받아주고 있다.

결과는 다음과 같다.

이번에는 위의 코드를 다음과 같이 변경해 보자.

void doSomething()
{
	MyArray my_array;

	try
	{
		my_array[100]; // index는 0 ~ 4 까지만 가능
	}
	catch (const int& x)
	{
		cerr << "Exception " << x << endl;
	}
 
	catch (Exception& e) // 객체를 받음
	{
		cout << "doSomething()" << endl;
		e.report();
		throw e;
	}
}

int main()
{
	try
	{
		doSomething();
	}
	catch (ArrayException& e)
	{
		cout << "main() Array" << endl;
		e.report();
	}
	catch (Exception& e)
	{
		cout << "main() Excep" << endl;
		e.report();
	}

	return 0;
}   
 

Exception &e로 받은후 다시 throw를 하여서 main에서 받도록 하는것 이다.

일반적인 생각으로는 ArrayException객체를 던진것 이니, catch (Exception &e)로 받은 후 다시 main으로 re throw를 통하여 던지면 ArrayException이 받을것이라 생각된다.

하지만 결과는 다음과 같다.

난 처음에 이결과를 보고, "객체잘림이 일어나서 이런결과가 나타나는구나" 라고 생각했다. 물론 객체잘림이 발생하는것은 맞다.

하지만 그렇다고해서 ArrayException으로 main에서 받는것을 못하는 것 이 아니다. 다음을 보자.

	catch (Exception& e) // 객체를 받음
	{
		cout << "doSomething()" << endl;
		e.report();
		throw;
	}

 

throw e가 아니라 그냥 throw이다. 이결과 ArrayException으로 받고있다.

 

14-4 exception 소개

이번시간에는 std::exception class에 대하여 배웠다. 이는 다양한경우의 예외에 대하여 구현되있기 때문에 우리가 구현할필요가 없어진다. include <exception>을 해줘야 사용이 가능하다.

#include <iostream>
#include <exception>
#include <string>

using namespace std;

int main()
{
	try
	{
		std::string s;
		s.resize(-1); // 인자의 길이가 음수면 내부에서 exception을 throw되도록 이미 구현되있음

	}
	catch (std::exception& exception)
	{
		std::cerr << exception.what() << std::endl;
	}

	return 0;
} 
 

위의 코드를 실행시키면 결과는 다음과 같다.

위의 주석에도 나와있듯 이미 이러한 기능이 구현되있는 것 이다. 이때 교수님께서 한가지 퀴즈를 던져주셨다.

"-1인데 왜 길다고 경고를 나타낼까? " 사실 난 듣자마자 바로 답했다. 아마 buffer overflow 때문일것 이다(?).

 

std::exception은 검색해보면 수많은 자식 class들을 갖고있다. 위의 코드의 경우 exception <- logic_error <- length_error 형태로 class가 구성된 것 이다.

class의 이름이 궁금하다면 다음과 같이 코드를 추가해주면 된다.

	catch (std::exception& exception)
	{
		std::cout << typeid(exception).name() << std::endl; // 추가
		std::cerr << exception.what() << std::endl;
	}
 

std를 사용할때는 내부에 이미 구현되있는 throw를 catch하는법에 대하여 배웠다.

또한 std::exception의 자식 class중 하나를 직접 throw로 던질수도 있다.

int main()
{
	try
	{
		throw std::runtime_error("Bad thing happened");
	}
	catch (std::exception& exception)
	{
		std::cout << typeid(exception).name() << std::endl;
		std::cerr << exception.what() << std::endl;
	}

	return 0;
}  
 

위의 코드는 std::exception의 자식 class 중 하나인 runtime_error을 throw하고있는 코드이다.

결과로는 class std::runtime_error Bad thing happened가 출력된다.

 

이번에는 Exception class를 새롭게 만드는데 기존의 std::exception을 상속을 받는경우에 대하여 알아보자.

class CustomException : public std::exception
{
public:
	const char* what() const noexcept override
	{
		return "Custom exception";
	}
};

int main()
{
	try
	{
		throw CustomException();
	}
	catch (std::exception& exception)
	{
		std::cout << typeid(exception).name() << std::endl;
		std::cerr << exception.what() << std::endl;
	}

	return 0;
}        
 

결과는 다음과 같다.

위의 코드는 std::exception을 상속받은후 what함수를 override해서 사용하는 방법을 보여주고 있다.

어떠한 오류가 발생하는건지 what()함수가 중요한데, 직접만든 Exception함수에서는 이함수를 꼭 override해줘야 한다.

 

14-5 함수 try

class의 생성자에서 발생하는 예외, 즉 초기화 list에서 발생하는 예외를 처리할때 많이 사용하는 funtion try 문법에 대하여 배웠다.

#include <iostream>
#include <exception>
#include <string>

using namespace std;

void doSomething()
try
{
	throw - 1;
}
catch (...)
{
	cout << "Catch in doSomething()" << endl;
}


int main()
{
	try
	{
		doSomething();
	}
	catch (...)
	{
		cout << "Catch in main()" << endl;
	}

	return 0;
}   
 

위의 코드를 보면 doSomething()뒤에 중괄호가 줄어 코드가 왼쪽에 붙어있는것을 볼 수 있다.

함수의 body 전체에 대하여 try_catch를 붙인것 이다. 이것이 기본적인 funtion try 형태 이기는 한데 잘 사용하지는 않는다 하셨다.

이보다 많이 쓰이는문법은 class의 constructor에서 예외가 발생할 경우 그것을 잡기위해 사용한다고 한다.

 

우선 일반적인 경우를 살펴보자.

class A {
private:
	int m_x;
public:
	A(int x) : m_x(x)
	{
		if (x <= 0)
			throw;
	}
};

class B : public A {
public:
	B(int x) : A(x) {}
};


int main()
{
	try
	{
		B b(0);
	}
	catch (...)
	{
		cout << "Catch in main()" << endl;
	}

	return 0;
}  
 

위의 코드는 main에서 b객체를 생성하면서 B의 생성자가 호출되고 이후 A의 생성자가 호출되어서 A의 생성자가 초기화를 할때 예외가 throw되고 있다.

이 예외는 main까지 와서 catch(...)에 잡힐 것 이고, 따라서 "Catch in main()"이 출력될 것 이다.

 

하지만 이렇게 main까지 넘어와서 catch하는 것 이 아니라, 생성자에서 바로 catch를 하고싶다면 어떻게 해야할까? 

이럴때 funtion try 문법을 사용한다.

class B : public A {
public:
	//B(int x) : A(x) {}
	B(int x) try : A(x)
	{
		// do initialization
	}
	catch (...)
	{
		cout << "Catch in B constructor " << endl;
		// throw;
	}
};
 

try를 하는데 initialization list까지 포함을 해서 전부 try를 하고 그다음 거기서 발생한 예외를 전부 catch를 하는 것 이다.

실행결과는 다음과 같다.

신기한점이 있다. class B안에서 catch를 통하여 잡은것은 이해가 간다. 이상한점은 class B안에서 throw를 추가하지 않았는데도 main에서도 catch하고 있다.

throw가 없지만 있는것 처럼 작동하고 있다. 생성자에서 funtion try를 사용했을때는 생성자에 있는 catch에서 한번잡은후 re throw 해준 것 "처럼" 한번더 잡아주고 있다.

 

14-6 예외처리의 위험성과 단점

- 1 예외처리를 하다보면 메모리 반환이 정상적으로 되지 않아 메모리 leak이 발생할 수 있다.

#include <iostream>

using namespace std;

int main()
{
	try
	{
		int* i = new int[1000000];

		throw "error";

		delete[] i;
	}
	catch (...)
	{
		cout << "Catch in main()" << endl;
	}

	return 0;
}   
 

위의 코드를 보면 정상적인 경우에는 메모리 할당이 이루어지고 반환을 하겠지만, 예외가발생하면 throw이후에 있는 delete[]문이 실행되지 않는다.

이럴때 스마트 포인터를 사용하면 좋다. 스마트 포인터는 include <memory>를 해주어야 한다.

#include <iostream>
#include <memory>

using namespace std;

int main()
{
	try
	{
		int* i = new int[1000000];
		unique_ptr<int> up_i(i); // 영역을 벗어나면 자동 메모리 반환

		throw "error";

		//delete[] i;
	}
	catch (...)
	{
		cout << "Catch in main()" << endl;
	}

	return 0;
}    
 

위의 코드를 보면 unique_ptr<int> up_i(i); 이 있는데, 이부분이 스마트 포인터 이다.

영역을 벗어날 경우 알아서 스마트포인터가 메모리를 반환해 준다.

 

- 2 소멸자 에서는 throw를 사용하면 안된다.

 

- 3 중첩된 반복문 안에서 빈번하게 사용하지는 말자. 성능하락으로 이어짐

 

2) 나의 현황

예외처리는 조건문으로 처리하는 것과는 다른 맥락의 문법인 것 같다.

어디에서 오류가 발생할지 알고 if문을 걸어주는것과 어디서 터질지 모르니 예외처리를 하는 차이랄까??

즐거운 C++공부 후딱 1월 안에 문법전부 끝내고 2월부터는 C++로 자료구조 복습과, STL위주로 공부할 예정이다.

3월에는 아마 알고리즘을 한달간 빡시게 달릴예정이며, 4월 초부터 PS를 시작할 예정이다.

이글의 모든 사진과 내용의 출처는 홍정모 교수님께 있습니다.

홍정모 교수님 블로그:

 

홍정모 연구소 : 네이버 블로그

안녕하세요! 홍정모 블로그에 오신 것을 환영합니다. 주로 프로그래밍 관련 메모 용도로 사용합니다. 강의 수강하시는 분들은 홍정모 연구소 카페로 오세요.

blog.naver.com

 

 

댓글