Effective Modern C++ - Scott Meyers
Item 14: Declare functions noexcept if they won't emit exceptions.
item 14. 예외를 발생하지 않는 함수인 경우 noexcept를 선언하자.
* Intro
특정 함수가 예외를 만들어 내느냐 아니냐는 것을 함수를 사용하는 사람은 알고 있어야 한다. 왜냐면 이 함수를 사용해서 만드는 code의 예외안정성에 영향을 미치기 때문이다. 즉 함수를 호출하는 사용자는 해당 함수의 noexcept여부를 알 수 있어야 한다.
noexcept를 붙인 함수는 Compiler가 좀 더 성능이 뛰어난 오브젝트를 생성할 수 있게 해준다. C++98 형식은 지원하지 않으며 noexcept라는 키워드를 붙인 경우만 해당된다.
int f(int x) throw(); // C++98 Style int f(int x) noexcept; // C++11 Style |
* Compile Optimization
만약 실행중(runtime)에 f에서 예외가 발생했다면 ?
C++98 스타일(throw())에서는 f를 호출한 쪽에서 바로 스택 풀기(stack unwinding)을 시도한다.
그러는 중 해당 예외를 처리하는 곳을 만나지 못하면 실행이 종료된다.
C++11스타일(noexcept)에서는 처리방법이 조금 다른데, 스택 풀기가 프로그램 종료 전에 실행될 수 있다.
컴파일러 마다 조금 다를 수 있는데 gcc는 unwindling을 수행하지 않고 종료하고, clang은 unwindling을 수행하고 종료한다.
이 두가지가 코드 생성에 큰 차이가 있을까 ?
noexcept를 사용한 경우는 예외가 전파되는 동안 런타임 스택을 유지할 필요도 없고, 함수내에서 생성한 객체들을 순서대로 소멸해야한다는 것도 지킬 필요가 없다.
throw()를 사용한 C++98 스타일에서는 이런 최적화 기능을 적용 할 수 없다. 물론 아무것도 안적은 경우도 마찬가지이고.
int f(int x) noexcept; // most optimizable
int f(int x) throw(); // less optimizable
int f(int x); // less optimizable
|
* Move operation
특정 함수에서는 이런 최적화에 따라 성능차이가 많이 난다.
그 중 가장 대표적인것이 move 연산자이다.
std::vector<Widget> vw;
Widget w;
vw.push_back(w);
|
위 code는 아무 문제가 없는 code이다.
하지만 우린 C++11의 move sementics에 대해서 알고 있다.
copy 대신 move를 이용하여 성능을 향상된다. (사실은 향상 될 수도 있다는 것이지 100% 향상되진 않는다.)
왜 100% 향상이 안되지 ? 한번 알아보자.
std::vector<T>에 push_back()을 할 경우 내부 버퍼가 꽉 찬 경우 (size == capacity) 새로운 공간을 2배 크기로 확보한 다음 기존 data를 복사한다. 복사를 완료한 후에 기존 공간을 삭제한다.
여기서 만약 복사하는 도중 exception이 발생한 경우 ? 이전 공간에 원본이 남이 있으므로 복구가 가능하다. 그냥 예전 공간을 다시 사용하면 된다.
즉 push_back() 은 강한 예외 안전성을 보장한다. (the strong exception safety guarantee)
그럼 이제 C++11 스타일인 move할 경우를 생각해보자. n개의 요소를 이동 완료한 후 n+1번째에서 예외가 발생했을 경우, 앞의 n개의 data에 대해서는 이미 이동을 했으니 다시 원래 상태로 돌아갈 수 있는 방법이 없을 수도 있다. (다시 n개를 원래대로 이동 시키면 된다고 생각하는데, 그 과정에 또 예외가 발생 할 수도 있다.)
이 문제는 심각한 문제이다. 결국 push_back()에서 move 연산자를 전혀 사용 못할 수도 있기 때문이다.
C++11 에서는 실제로 어떻게 동작할까 ?
예외 발생이 가능한 경우는 move대신 copy를 한다.
예외가 발생하지 않는다고 확인되면 move를 사용한다.
이게 최선이다. 이렇게라도 해야 성능을 끌어 올릴 수 있다.
C++98에서 강한 예외 안전성을 보장하는 함수들 중에 이런 식으로 동작하는 함수들은 모두 C++11에서 이렇게 동작하도록 수정되었다.
e.g. std::vector::reserve, std::vector::insert ...
그런데 move 연산이 예외를 내지 않는 다는 것을 어떻게 알 수 있을까 ?
그냥 noexcept가 선언되었는지를 본다.
이런 예제를 하나 더 살펴보자.
swap 함수는 STL 내부에서 아주 많이 사용된다.
template <class T, size_t N>
void swap(T(&a)[N],
T(&b)[N]) noexcept(noexcept(swap(*a, *b)));
template <class T1, class T2>
struct pair {
...
void swap(pair& p) noexcept(noexcept(swap(first, p.first)) &&
noexcept(swap(second, p.second)));
...
}; |
이런 함수들은 조건절 noexcept(conditionally noexcept)의 예제이다.
noexcept는 bool을 반환하는 expr을 입력받는 연산자이다. 아무것도 안적으면 noexcept(true) 인 것이다.
Widget의 배열 2개가 주어졌을 경우, 위 조건을 보면 각 원소들의 swap함수가 noexcept인 경우에 대해서 위 예제의 swap함수도 noexcept속성이 된다. (즉 Widget에 대한 swap함수가 noexcept라면 위 하무도 noexcept로 동작한다.)
pair의 예제도 마찬가지로 first의 타입인 T1과 second의 타입인 T2끼리의 swap함수가 noexcept인 경우에만 noexcept로 동작한다.
* Optimization < Correctness
최적화 (Optimization)은 중요한 요소이다. 하지만 이보다 더 중요한건 정확성(Correctness)이다.
noexcept는 함수의 interface가 된다.
한번 noexcept를 붙인 함수에서 noexcept를 삭제하는 것은 무척 쉽지만, 그렇게 하면 이 함수를 사용한 다른 code의 안전성에 심각한 문제를 만들 수도 있다.
그럼 noexcept를 그대로 놔두고 함수 안에서 예외를 발생시키면 ? 프로그램이 죽는다.
그러니깐 아에 처음부터 noexcept를 쓸지 말지 심사숙고해서 결정해야 한다.
함수에서 예외를 발생 할 수 있는 것이 훨씬 더 일반적이다. 함수 내에서 사용된 code에서만 예외를 발생하지 않게는 만들 수 있을지언정 거기서 사용하는 함수에서 예외를 발생한다면 ??? 이 함수는 결과적으로 예외를 발생시킬 수 있는 함수가 된다. 결국 noexcept를 붙이면 안된다는 말이다.
noexcept를 붙일 수 있는 함수를 만드는 경우는 어찌보면 아주 드물다. (swap 이나 move 연산자 같은 경우...)
절대 예외를 내지 않을 것 같은 함수에는 최대한 noexcept를 붙이자.
그렇다고, noexcept를 붙이기 위해 code를 꼬아버리면 오히려 배보다 배꼽이 큰 경우가 된다. 그렇게 꼬아버리는 code가 noexcept로 인해 얻을 수 있는 성능상의 이점보다 더 커져버릴수 있다. return 값에 error code 값을 넣어서 그걸 받은 쪽에서 해석을 해야 한다던지 이런 함수는 정말 안좋은 예이다.
이렇게 예외를 숨기거나 꼬아버린 code는 절대 좋은 code가 아니다. 이 함수를 다른 사람이 해석하는 것도 힘들어지고, 나중에 유지보수 할때도 엄청난 시간을 허비해야 할 것이다.
* default로 noexcept 속성을 가지는 destructor
특정 함수에 대해서는 noexcept가 정말 중요해서 default로 선언되어지는 경우도 있다.
C++98 에서 메모리 해제 (delete, delete[]) 함수와 소멸자(destructor)에서 예외가 나면 많이 난감하다.
C++11에서는 이게 좀 더 업그레이드되어 noexcept를 붙여주지 않아도 암묵적으로 선언된 것으로 간주한다.
예외가 발생할 수 있는 경우는 명시적으로 noexcept(false)라고 붙여주어야 한다. STL내에는 예외 발생가능한 소멸자는 없다.
그렇다고 모든 소멸자에 다 noexcept를 default로 붙여주는건 아니다. class 내의 멤버 변수의 소멸자가 모두 noexcept인 경우만 해당된다. (물론 상속받은 class 의 멤버 변수 뿐만 아니라, 그 멤버 변수 내의 멤버 변수도 모두 noexcept인 경우)
* 사전 Check 조건이 있는 경우는 noexcept를 쓰지 말자.
라이브러리 인터페이스 디자이너 관점에서 보면 함수를 2가지 타입으로 나눌 수 있다.
- Wide contracts : 함수를 호출하기 위한 사전 조건이 존재하지 않는다.
- Narrow contracts : Wide contracts를 만족하지 못하는 모든 경우
Narrow contracts 형식의 함수에는 noexcept를 적용해야 할까 하지 말아야 할까 ?
void f(const std::string& s) noexcept; //
precontidion : s.length() <= 32 |
위 함수의 경우 사전조건이 없고, 절대 예외가 발생 하지 않는 함수라면 당연히 noexcept를 붙여야 한다.
하지만 문자열의 크기가 32자 이하여야 한다는 조건이 붙는다면 어떻게 해야 할까 ?
함수 내부에 문자열의 길이를 Check하는 code를 넣어야 할 것이다.
(꼭 넣을 필요는 없지만, 그래도 예외가 발생하는 상황에서 디버깅하는게 정해지지 않은 경우에 대한 디버깅보다는 쉬울 것이다.)
문자열 길이를 Check하여 사전 조건 실패 예외 (precondition violation exception)를 발생시켜야 한다.
이 함수는 절대로 noexcept로 선언되어서는 안된다.
실제로 라이브러리 인터페이스 설계자들은 narrow contracts 속성을 지닌 함수에는 noexcept를 선언하지 않는다.
* noexcept 함수 내부에서의 noexcept check ?
void setup();
void cleanup();
void init() noexcept
{
setup();
// do something
cleanup();
}
|
위 예제를 보면 init()은 noexcept로 선언되었는데, setup()과 cleanup()은 noexcept로 선언되지 않았다.
잘못된 것일까 ? 아니다. C-Style 함수라던지, C++98 에서 이미 개발된 함수라면 당연한 이야기이다. (그땐 noexcept라는 키워드 자체가 없었다.)
그래서 noexcept함수 내부에서 사용된 함수들에 대해서 noexcept여부는 체크하지 않는다.
Things to Remember * noexcept는 함수 인터페이스에 속한다. 그러므로 해당 함수 사용자는 noexcept여부에 대해서 알아야 한다. * noexcept로 선언된 함수는 아닌 함수보다 더 성능상의 이점을 볼 수 있다. * move 연산, swap, 메모리 해제 함수, 소멸자 등에서의 noexcept여부는 아주 중요하다. * 대부분의 일반 함수들은 noexcept로 선언하기 보다는 예외를 처리하는 함수로 선언되는게 더 자연스럽다. |
댓글 없음:
댓글 쓰기