Search

CPP Module 05

Created
2021/10/11
tag
42서울
42Seoul
CPP Module
All of C++98
Exception
try-catch
throw

Subjects

1. ex00 (Mommy, when I grow up, I want to be a bureaucrat!)

Exception & Throw

ex00은 단 하나의 클래스를 정의하므로, 문제에서 요구하는 것들을 Orthodox Canonical Form을 맞춰 구현하면 된다. 다만 Exception이라는 다소 생소한 개념을 이용해야 하는데, 일반적으로 발생하는 에러들이 Exception에 속한다고 보면된다. 특히 C++에서는 <exception>std::exception이라는 클래스가 정의되어 있으며, 모든 에러들은 std::exception을 기반 클래스로 이용하여 구현되어 있다. 따라서 이를 상속받아 사용자가 원하는 에러를 직접 정의할 수도 있고, <stdexcept>에서 필요한 에러를 직접 가져다 쓸 수도 있다.
#include <exception> #include <iostream> int main(void) { std::string key; while (true) { std::cin >> key; if (key == "cause") throw (std::exception()); } return (0); }
C++
복사
std::exception 객체를 이용할 때는 위 코드처럼 throw 키워드를 이용하여 에러를 발생시킬 수 있다. 에러를 발생시키는 것이 좋다 나쁘다를 떠나서 작성된 코드가 실행될 때 원하지 않는 동작이 일어날 경우, 에러를 발생 시키고 이에 대해 적절한 대처를 하는 것이 굉장히 중요하기 때문에 std::exception 객체를 잘 알아두는 것이 좋다.

Custom Exception & what Member Function

class CustomException : public std::exception { public: const char* what(void) const throw() { return ("CustomException"); } };
C++
복사
이전에 제시된 코드처럼 std::exception을 그대로 이용할 수도 있지만, 사용자가 원하는 에러를 정의하고 싶다면, 위 코드처럼 std::exception을 상속하여 직접 에러를 정의할 수 있다. 코드를 보면 정의된 에러에 what이라는 멤버 함수를 정의한 것을 볼 수 있는데, 이는 std::exception에서 정의된 virtual로 명시된 함수를 Overriding한 것이다. 이 때 what 함수의 원형을 통해 몇 가지 살펴볼 사항들이 있다.
첫 째로 const char *를 반환한다는 점에 유의해야 한다. std::string을 반환하는 것이 아니기 때문에 what 함수 내부에 정의한 std::string은 반환될 수 없으며, 혹여나 std::stringc_str이라는 멤버 함수를 이용하더라도 std::stringwhat 함수 내부에 정의된다면 반환하려는 문자열의 원본 객체가 사라지므로 Dangling에 처하게 된다. 따라서 제시된 예시처럼 리터럴 값을 이용하거나 외부에서 what에서도 접근할 수 있는 적절한 값을 이용하는 것이 좋다.
둘 째는 throw()라는 키워드이다. 가장 위에서 제시된 예시에서 std::exception을 발생시킬 때 throw라는 키워드를 이용했는데, 이 때의 throw는 런 타임에 이용되는 키워드이다. 반면에 함수 원형에 사용된 throw는 컴파일 타임에 이용되는 키워드이며, 이 때문에 throw()는 고유한 하나의 구문으로 이용된다. 따라서 함수 원형에서 throw()throw(void)와 혼동해선 안 된다.
함수 원형에 throw(void)를 이용하면 컴파일이 되지 않는다.
함수 원형에 사용된 throw에 대해서 조금 더 언급하자면, C++17 이전에 사용하는 Exception Specification은 2가지가 있다. 첫 째는 Noexcept Specification이라 하여, C++11에서 새롭게 소개된 noexcept 키워드이다. 이는 함수를 비정상적으로 탈출할 수 있는 잠재적인 Exception이 없다는 것을 알리는데 이용된다. 둘 째는 Dynamic Exception Specification이라 하여, throw($optional-type-list)를 이용하는 것이다. Dynamic Exception SpecificationC++11에서 Deprecated되었고, C++17에서는 throw()를 제외하고 모두 사라졌다. Dynamic Exception Specification은 함수에서 어떤 종류의 Exception이 발생할 수 있는지 함수 원형에서 간단하게 파악할 수 있도록 일종의 요약 기능을 했었는데, 실제로 사용했을 때는 문제점이 많다는 지적 때문에 사라진 것이다. 하지만 throw()만큼은 다소 유용하게 사용될 수 있다는 것이 증명되어 throw()는 남아 있을 수 있었다.
위에서 설명한 것이 말이 조금 어려울 수 있는데 Exception Specification이라 하는 것은 에러 발생 여부를 명시하는 것이라고 이해하면 된다. 이 때 결과적으로 활용 가능한 키워드들은 throw()noexcept가 되는데, Modern C++에서는 noexcept를 주로 이용한다. 즉, throw()의 기능을 noexcept가 대체할 수 있다는 것인데, 실제로 throw()noexcept(true)의 별칭으로 동작한다. 그리고 noexcept(true)에서 알 수 있듯이, noexcept는 키워드 자체로 이용할 수도 있고 noexcept($expression) 형태로 이용하는 것도 가능하다. $expression 부분이 참인지 거짓인지에 따라 에러가 발생하는 함수인지 아닌지로 이해하게 된다.
#include <exception> #include <iostream> class CustomException : public std::exception { public: const char* what(void) const throw() { return ("CustomException"); } }; int main(void) { CustomException e; std::cout << e.what() << std::endl; return (0); }
C++
복사
what 함수 원형에 대해 이해했기 때문에, 이를 직접 호출하여 어떤 역할을 하는지 살펴보자. main 함수에 작성된 예시는 그리 좋은 예시는 아니지만, what 함수의 역할을 이해하기엔 충분하다. 실행 결과를 보면 알 수 있듯이 what 함수는 정의된 에러에 대한 문자열을 반환한다.

Try ? Catch !

#include <exception> #include <iostream> class CustomException : public std::exception { public: const char* what(void) const throw() { return ("CustomException"); } }; int main(void) { try { std::string key; while (true) { std::cin >> key; if (key == "cause") throw (CustomException()); } } catch (std::exception& e) { std::cerr << e.what() << std::endl; } std::cout << "Program End Normally" << std::endl; return (0); }
C++
복사
Exception을 정의하고 이를 throw로 발생시킬 수 있는 것은 알게 되었는데, throwException은 결과적으로 프로그램 실행 흐름을 끊고 프로그램의 종료로 이어진다. 하지만 try-catch 를 이용하면 발생된 Exception에 대해서 프로그램의 비정상적인 종료를 방지하고, 발생된 Exception의 후속 작업을 정의하여 적절하게 처리할 수 있다. 주어진 예시를 보면 기존에 정의한 CustomException을 발생시켰을 때 catch 구문에서 잡을 수 있는 것을 볼 수 있다. 이 때, Exception이 발생할 수 있는 구문들이 try 구문의 범위에 속해있지 않으면 try-catch 쌍으로 붙은 catch에서는 Exception을 잡을 수 없다. 즉, catch에서 잡고 싶은 Exception은 반드시 쌍으로 붙은 try 내에서 시도되어야 한다.
#include <exception> #include <iostream> class CustomException : public std::exception { public: const char* what(void) const throw() { return ("CustomException"); } }; int main(void) { while (true) { try { std::string key; while (true) { std::cin >> key; if (key == "cause") throw (CustomException()); if (key == "int") throw (1); if (key == "double") throw (0.5); } } catch (std::exception& e) { std::cerr << e.what() << std::endl; } catch (int& i) { std::cerr << "Int Caught" << std::endl; } catch (double& d) { std::cerr << "Double Caught" << std::endl; } std::cout << "Program End Normally" << std::endl; } return (0); }
C++
복사
그리고 catch를 이용하여 Exception을 잡을 때 std::exception& e 라는 구문이 이용된 것을 볼 수 있는데, 일반적으로 Exception들은 std::exception기반 클래스로 이용하기 때문에 어지간한 Exception들은 std::exception& e 구문으로 모두 잡아낼 수 있다. 하지만 throwException에 대해서만 수행할 수 있는 것은 아니다. 위 예시를 보면 경우에 따라서 int 혹은 double 등이 throw되는 것을 볼 수 있는데, 이는 다중으로 작성된 catch를 이용하여 잡아낼 수 있다.
#include <exception> #include <iostream> class CustomException : public std::exception { public: const char* what(void) const throw() { return ("CustomException"); } }; int main(void) { while (true) { try { std::string key; while (true) { std::cin >> key; if (key == "cause") throw (CustomException()); if (key == "int") throw (1); if (key == "double") throw (0.5); } } catch (std::exception& e) { std::cerr << e.what() << std::endl; } catch (...) { std::cerr << "Numeric Caught" << std::endl; } std::cout << "Program End Normally" << std::endl; } return (0); }
C++
복사
그렇다면 Exception에 대해서 모든 catch 구문을 작성하지 않고, 상황에 따라 int, double 등을 한 번에 묶어서 처리하고 싶은 경우에는 어떻게 해야할까? 그 답은 예시를 보면 알 수 있다. catch... 를 사용하여 나머지 경우들을 모두 처리할 수 있다. 단, 프로그램이 Exception에 대해서 적절한 처리를 할 수 있도록 작성된 것이 아니라면, ... 을 무분별하게 사용하는 행위에 주의해야 한다.
일반적으로 catch... 에서는 Exception 발생을 기록하는 작업 등을 수행한다.

Stack Unwinding

기본적으로 Exceptionthrow되면, throw 구문 이후로는 코드가 실행되지 않는다. 따라서 제시된 예시들을 적절히 수정해서 throw 이후의 출력문들을 작성해보면, 해당 출력문들은 제 기능을 하지 않고 catch로 빠지는 것을 볼 수 있다. 그렇다면 만일 특정 함수에서 객체를 이용하고 있었고, throwException을 던졌다면 객체는 적절히 소멸될 수 있을까?
만일 객체들이 오토매틱으로 선언되었다면, throw로 던져진 Exceptioncatch 구문이 존재하는 스택 프레임에 도달하기 전까지 적절하게 자신의 소멸자를 호출하게 된다. 이를 Stack Unwinding이라 한다. 해당 기능은 함수 호출을 마치고 스택 프레임을 걷어내면서 오토매틱들이 소멸되는 것과 동일하게 동작하는데, 이는 Exception이 발생했을 때도 오토매틱들이 정상적으로 소멸하는 것을 보장하는 역할을 한다.
다만 주의해야 할 점은 Stack Unwinding오토매틱에 대해서만 동작하기 때문에, 자유 공간에 할당된 객체는 해당되지 않는다는 것이다. 만일 원시 포인터new로 생성한 객체를 할당했다면, 이는 메모리 누수로 이어질 수 있다. Exception 발생 지점과 catch의 시점이 동일한 스택 프레임 내에서 이뤄진다면 catch 구문에서 어찌저찌 메모리를 해제할 수는 있지만, catch의 시점이 동일하지 않다면 동일한 시점에 catch를 한 번 더 작성하고 다시 throw를 던지는 불필요한 작업이 생긴다. 따라서 자유 공간에 할당된 객체는 Stack Unwinding 시에 소멸자를 호출하지 않는다는 점을 명심하여 코드를 작성해야 한다.
원시 포인터의 약점이 지난 Module 04 글에서도 언급되었는데, Stack Unwinding 시에 발생할 수 있는 문제 역시 스마트 포인터로 해결할 수 있다. 스마트 포인터는 객체이므로 Stack Unwinding 시에 소멸자를 호출할 수 있고, 소멸자 내에서 참조 대상의 메모리 반환을 유도할 수 있다.
Exception이 무엇인지, throwtry-catch이 무엇인지 이해했기 때문에 충분한 Error Handling이 가능하다. 남은 ex01 - ex03까지 적절하게 Error Handling을 하면서 코드를 작성하면 되는데, 매번의 작업마다 throw를 던지게 만들어서 반복된 try-catch를 작성하는 것을 최대한 지양하자. Error Handling이 귀찮은 작업으로 남게 만들지 말고, 프로그래밍을 하는 입장에서 더 편리한 수단이 될 수 있도록 적절한 구조를 생각하는 습관을 만들자.

Bureaucrat Exception

문제에서 제시한 Bureaucrat 클래스에서는 GradeTooHighExceptionGradeTooLowException을 작성하면 된다고 했으므로, 위에서 이해한 것을 토대로 작성해주면 된다. 그리고 incrementGrade 함수와 decrementGrade 함수를 호출 했을 때, 문제가 되는 상황에서 기존에 정의한 Exception들을 적절히 발생시켜주면 된다. 다만, IncrementDecrement의 방향에 대해서 주의하자.

2. ex01 (Form up, maggots!)

Make Exception

ex00Bureaucrat 클래스처럼, Form 클래스 역시 적절한 Grade를 갖고 있지 않으면 Exception을 던지도록 만들어야 한다. 이와 같은 작업은 Form 클래스 생성 단계의 생성자에서 처리하면 된다.

Const Variable

Form 클래스의 멤버 변수들은 대체적으로 const로 정의되어 있는데, 이 때문에 operator=에서 어려움을 겪을 수 있다. 해당 변수들은 Module 02에서 배웠던 것처럼 const_cast<T>를 이용하여 const 값을 바꾸는 것이 가능하다.

Less Code

Form 클래스beSigned 함수와 Bureaucrat 클래스signForm 함수가 있는데, 이들을 main 함수에서 일일이 호출하는 작업을 거치게 되면 try-catch 구문 역시 일일이 나열하여 작성해야 한다. 서브젝트에서는 beSigned 함수에서 서명을 할 수 없을 때 Exception을 던지는 것으로 기능을 정해두었고, signForm에서는 서명 수행 여부를 출력하라고 나와 있기 때문에 두 함수를 엮을 수 있음을 알 수 있다. 따라서 인자로 받은 Form 클래스의 객체를 이용하여 signForm 함수 내에서 beSigned 함수를 호출하도록 만들고, 이와 같은 과정을 signForm 함수 내에서 try-catch로 묶어서 결과까지 출력하도록 만들면 main 함수에서 많은 코드들을 아낄 수 있다.

3. ex02 (No, you need form 28B, not 28C...)

Turn into Abstract Class

ex01에서 Form 클래스만 꼼꼼히 작성했다면, ex02에서는 크게 해줄 것이 없다. 새롭게 생기는 PresidentialPardonForm 클래스 (이하 P 클래스), RobotomyRequestForm (이하 R 클래스), ShrubberyCreationForm (이하 S 클래스)를 정의해야 하고, Form 클래스execute를 각 클래스에 맞게 Overriding 하면 된다. 또한 Form 클래스추상 클래스로 만들어야 되므로, execute 함수를 Pure Virtual Function으로 선언하여 해결했다. 그리고 잊지 않고 소멸자virtual을 명시하자.
멤버 변수들은 문제 조건에 따라 protected로 바꿔선 안 된다.

More Reusable

Form 클래스들의 execute 함수 추가에 따라, Bureaucrat 클래스에서도 executeForm 함수를 추가해야 한다. 단, 내 경우에는 ex01signForm과 마찬가지로 executeFormexecute를 따로 호출하지 않도록, 인자로 받은 Form 클래스의 객체로 execute 함수를 executeForm 함수 내에서 호출하는 식으로 작성했다. 이 때의 try-catchexecuteForm에 걸어두었고, execute 함수에서 발생한 Exception까지 잡아서 결과를 출력하게 만들어 main 함수에서 작성할 try-catch를 아끼는 방향으로 구현했다.
특히 execute 함수 내에서는 실행 여부 등을 파악하여 실행이 불가능할 때는 Exception을 던지도록 만들어야 하는데, 실행 여부의 파악이 3개의 클래스에서 모두 중복된다. 따라서 이 부분도 Form 클래스에 미리 정의해두면, 3개의 파생 클래스에서도 이를 재사용할 수 있으므로 execute 함수에서는 단순히 실행 여부 파악을 위한 함수를 호출하기만 하면 된다. 문제가 없다면 다음 코드로 넘어갈 것이고, 문제가 있다면 Exception에 의해 execute 함수의 나머지 부분은 실행되지 않을 것이다.
전체적으로 서명이 되지 않았을 때의 Exception과 특히 S 클래스에서는 std::ofstream에 대한 Exception도 추가되면 좋을 것이다.
그리고 서브젝트에서 주어진 target을 어떻게 해석하는지에 따라 다르겠지만, 나는 targetname으로 생각하여 새롭게 정의한 3개의 클래스에 추가적인 멤버 변수를 두지 않고 기존의 Form 클래스를 그대로 이용하게 두었다. 이 때 각 클래스가 자신의 타입을 알 수 있도록 Form 클래스type을 정의했고, 각 클래스에 맞는 타입을 이용하도록 생성자에서 그 값을 갱신하도록 작성했다. 덕분에 새롭게 정의한 3개의 클래스에는 execute 함수를 제외하면, 이름 있는 생성자Orthodox Canonical Form 밖에 없기 때문에 대부분의 연산을 Form 클래스의 연산으로 대체할 수 있었다.
각 클래스의 타입을 갱신할 때 클래스마다 사용하는 값이 다른데, 코드의 적응성을 높이기 위해 #define으로 각 타입을 정의해두었다.
PresidentialPardonForm& PresidentialPardonForm::operator=(const PresidentialPardonForm& p) { return (*(dynamic_cast<PresidentialPardonForm*>(&(Form::operator=(p))))); }
C++
복사
P 클래스를 예로 들자면, 파생 클래스의 할당 연산은 Form::operator=를 호출하도록 두었다. 이 때의 반환 값은 해당 멤버 함수를 호출한 P 클래스의 객체가 되는데, Form::operator=의 반환은 Form 클래스의 객체이므로 P 클래스참조자다운 캐스팅이 되어 런 타임에 문제가 발생하게 된다. 이는 컴파일 타임에 발생하는 에러가 아니므로 꽤나 치명적이다. 따라서 해당 경우에는 P 클래스 → Form 클래스 → P 클래스의 형 변환이 이뤄지는 것을 통해 Form 클래스의 반환이 P 클래스 임을 보장할 수 있으므로, 위 코드처럼 dynamic_cast<T>를 이용하여 문제를 해결할 수 있다.
메모리 레이아웃에서 파생 클래스기반 클래스를 갖고 있어서 업 캐스팅이 가능하지만, 기반 클래스파생 클래스를 갖고 있지 않을 수 있으므로 다운 캐스팅에 엄격하다.
PresidentialPardonForm::PresidentialPardonForm(const PresidentialPardonForm& p) : Form(p) { setType(P_NAME); }
C++
복사
operator= 말고도 복사 생성자 같은 경우에는 단순히 Form 클래스생성자를 호출하기만 해도, 멤버 변수들의 복사를 만들어 낼 수 있으므로 P 클래스에서는 추가적인 코드 작성 없이 마칠 수 있다.

Random

C 언어 스타일을 지양하라고 했지만, <random>C++11부터 지원한다. 따라서 Module 01에서 구현한 것처럼 직접 Random 클래스를 만들거나, C 언어에서 지원하는 srandrand 함수를 활용하면 된다.

Multiline String Literal on std::string

S 클래스execute를 정의하기 위해선 숲으로 보일 수 있는 나무들을 ASCII-Art로 작성해야 하는데, 이를 해결하기 위해 일일이 std::cout << 혹은 std::stringoperator+=를 이용하는 방법을 떠올릴 것이다. C++11에서는 Raw Literal을 제공하기 때문에 Multiline String을 쉽게 작성할 수 있는데, C++98에서도 이와 비슷하게 작성하는 것이 가능하다. 이 방법은 std::stringoperator= 혹은 생성자를 이용한 방식이고, 매번 operator+=를 호출하던 방식에서 탈출할 수 있다.
std::string shrubbery = " ,@@@@@@@,\n" " ,,,. ,@@@@@@/@@, .oo8888o.\n" " ,&%%&%&&%,@@@@@/@@@@@@,8888\\88/8o\n" " ,%&\\%&&%&&%,@@@\\@@@/@@@88\\88888/88'\n" " %&&%&%&/%&&%@@\\@@/ /@@@88888\\88888'\n" " %&&%/ %&%%&&@@\\ V /@@' `88\\8 `/88'\n" " `&%\\ ` /%&' |.| \\ '|8'\n" " |o| | | | |\n" " |.| | | | |\n" " \\\\/ ._\\//_/__/ ,\\_//__\\\\/. \\_//__/_";
C++
복사
흥미롭게도 위와 같은 std::string을 정의할 수 있다. 열심히 " 쌍을 맞춰보고 있다면, 위 할당이 사뭇 어색하다는 것을 느낄 수 있다. 하지만 위 구문은 정상적인 구문이다. 컴파일러는 프로그램을 만들어 낼 때 문자열 리터럴으로 작성한 부분들의 파싱도 당연히 수행하는데, 이 때 코드 상에 연속해서 붙은 문자열 리터럴은 하나의 문자열 리터럴로 파싱하게 된다. 따라서 위와 같은 구문을 정상적으로 인식할 수 있는 것이다.

4. ex03 (At least this beats coffee-making)

Intern such as Factory

Intern 클래스를 정의하면 되고, Intern 클래스Form 클래스를 객체화하여 반환해주는 일종의 Factory Pattern 역할을 해준다. 이 때 해당 기능을 수행하는 makeForm 함수의 Exception을 어느 범위에서 처리할 것인지 정할 필요가 있다. makeForm 내에서 올바르지 않은 타입에 대해 Exceptionthrow 했을 때, try-catch를 외부에서 하여 makeForm을 즉시 벗어나도록 짜는 것도 가능하고, try-catchmakeForm 내에서 하여 밖에서는 반환 값 검증만 하고 try-catch를 하지 않도록 짜는 것도 가능하다. 내 경우에는 반복적인 try-catch를 쓰기 싫어서 makeForm 내부에서 try-catch를 적용한 후, 외부에서는 반환 값의 검증만 간단히 해주었다.

Escape Nested Cases

서브젝트에서 요구하는 바는 지저분한 if문들을 사용하지 말라는 것이다. 이는 Module 01멤버 함수 포인터를 이용한 것과 거의 유사한 구조를 갖는데, 여기서는 굳이 멤버 함수 포인터까지 쓸 필요는 없다. 3개의 if문과 각 조건에 따른 코드는 for문 1개, if문 1개, switch 1개로 대체될 수 있다. 출력 문구까지 고려하면 지저분한 if문들 보다는 더 나은 결과를 보인다.