Post List

2015년 4월 2일 목요일

item 14. 예외를 발생하지 않는 함수인 경우 noexcept를 선언하자. (Study PT Version)

작가
Meyers, Scott
출판
O'ReillyMedia
발매
2014.12.12
평점








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로 선언하기 보다는 예외를 처리하는 함수로 선언되는게 더 자연스럽다.