KoreanFoodie's Study

Effective Modern C++ | 항목 14 : 예외를 방출하지 않을 함수는 noexcept 로 선언하라 본문

Tutorials/C++ : Advanced

Effective Modern C++ | 항목 14 : 예외를 방출하지 않을 함수는 noexcept 로 선언하라

GoldGiver 2022. 10. 26. 09:54

C++ 프로그래머의 필독서이자 바이블인, 스콧 마이어스의 Modern Effective C++ 를 읽고 기억할 내용을 요약하고 있습니다. 꼭 읽어보시길 추천드립니다!

항목 14 : 예외를 방출하지 않을 함수는 noexcept 로 선언하라

핵심 :

1. noexcept 는 함수의 인터페이스의 일부이다. 이는 호출자가 noexcept 여부에 의존할 수 있음을 뜻한다.
2. noexcept 함수는 비 noexcept 함수보다 최적화의 여지가 크다.
3. noexcept 는 이동 연산들과 swap, 메모리 해제 함수들, 그리고 소멸자들에 특히나 유용하다.
4. 대부분의 함수는 noexcept 가 아니라 예외에 중립적이다.


함수를 noexcept 로 선언할 것인지의 여부는 인터페이스 설계상의 문제인데, 이는 클라이언트에게 아주 중요한 요소이다. 이는 호출 코드의 예외 안정성이나 효율성에 영향을 미치기 때문이다. 그리고 예외를 만들지 않는 함수에 noexcept 를 적용하면 컴파일러가 더 나은 목적 코드(object code) 를 산출할 수 있다. 이를 표현하는 방법은 두 가지 이다.

int f(int x) throw(); // C++98 방식
int f(int x) noexcept; // C++11 방식

C++98 에서는 예외 명세가 위반되면(예외가 f 바깥으로 튀어나오면) 호출 스택이 f 를 호출한 지점에 도달할 때까지 풀리며(unwind), 그 지점에서 몇 가지 동작이 취해진 후 프로그램 실행이 종료된다. C++11 에서는 프로그램 실행이 종료되기 전 호출 스택이 풀릴 수도 있고 풀리지 않을 수도 있다.
호출 스택이 풀리는 것과 풀릴 수도 있는 것은 컴파일러의 코드 작성에 매우 큰 영향을 미친다. noexcept 함수에서 컴파일러의 최적화기(optimizer) 는 예외가 함수 바깥으로 전파될 수 있다고 해도 실행시점 스택을 풀기 가능 상태로 유지할 필요가 없다. 또한, 예외가 noexcept 함수를 벗어난다고 해도 noexcept 함수 안의 객체들을 반드시 생성의 반대 순서로 파괴해야 하는 것도 아니다. 그러나 예외 명세가 "throw()"인 함수에는 그러한 최적화 유연성이 없으며, 예외 명세가 아예 없는 함수 역시 마찬가지로 그런 유연성이 없다. 다음의 요약 코드를 보자.

returnType f(parameter...) noexcept; // 최적화 여지 큼
returnType f(parameter...) throw(); // 최적화 여지 작음
returnType f(parameter...); // 최적화 여지 더 작음

 

코드의 동작이 강한 예외 안전성에 의존하는 경우도 있다. 예를 들어, std::vector 의 경우 C++98 에서는 push_back 으로 새 요소를 추가할 때 용량이 부족하면 메모리를 새로 할당하고 각 요소들을 일일히 복사하고 기존 메모리에 있는 객체들을 파괴한다. 이때, 모든 동작이 완료되기 전까지는 기존 메모리의 요소들이 파괴되지 않아 중간에 예외가 던져져도 std::vector 의 상태가 변하지 않았다(강한 예외 안정성 보장).

C++11 에서는 최적화를 위해 std::vector 요소들의 복사를 이동으로 대체했지만, 요소들을 옮기는 과정에서 예외가 발생하게 되면 push_back 연산이 실패할 뿐만 아니라, 기존의 std::vector 가 수정된 상태로 남게 된다. 기존 코드의 행동이 push_back 이 보장하는 강한 예외 안전성에 의존할 수도 있으므로, 이는 심각한 문제이다.

따라서 C++11 컴파일러는 이동 연산이 예외를 방출하지 않음이 확실한 경우에만 복사를 이동으로 대체한다(가능하면 이동하되 필요하면 복사하는 전략). 그리고 이때 확인하는 것이 noexcept 키워드이다!

 

또 다른 예시로 swap 이 있다. 다음 코드를 보자. 

// 배열의 첫 요소가 noexcept 인지 체크
template<class T, size_t N>
void swap(T (&a)[N],
  T (&b)[N]) noexcept(noexcept(swap(*a, *b)));

// 현재 struct 의 first 와 swap 하려는 대상(p) 의 first 의
// swap 이 noexcept 인지를 체크 (second 도 동일)
template<class T1, class T2>
struct pair
{
  void swap(pair& p) 
    noexcept(noexcept(swap(first, p.first)) &&
    noexcept(noexcept(swap(second, p.second)));
};

이 함수들은 조건부 noexcept 이다. 즉, 이들이 noexcept 인지의 여부는 noexcept 절 안의 표현식들이 noexcept 인지에 의존한다. 예를 들어 Widget 배열이 두 개 있을 떄, 그 둘을 교환하는 swap 은 오직 배열의 개별 요소들의 swap 이 noexcept 일때만 noexcept 이다. 따라서, Widget 배열들에 대한 swap 이 noexcept 인지는 Widget 을 위한 swap 을 작성한 프로그래머가 결정한다. 이는  std::pair 의 경우에도 마찬가지이다. 결론적으로, 더 높은 수준의 자료구조들의 교환이 일반적으로 noexcept 인지의 여부가 오직 더 낮은 수준의 구성요소들의 교환이 noexcept 인지의 여부에 의존한다는 사실은, swap 함수를 작성할 때 가능한 한 항상 noexcept 를 지정하다는 것이 바람직하다는 좋은 이유가 된다!

사실 최적화보다는 정확성(correctness) 가 더 중요하므로, 함수의 구현이 예외를 방출하지 않는 다는 성질이 바뀌지 않을 것이라는 생각이 드는 경우에만 함수를 noexcept 를 선언하자. 함수를 noexcept 로 선언한 후, 나중에 noexcept 를 제거하면 이를 수습할 방안이 딱히 없기 때문이다(기존의 강한 예외 안전성에 의존하던 코드들이 깨질 수 있으니까).

중요한 것은, 대부분의 함수가 예외에 중립적(exception-nuetral) 이라는 것이다. 예외 중립적 함수는 스스로 예외를 던지지는 않지만, 예외를 던지는 다른 함수를 호출할 수는 있다.

C++11 에서는 모든 메모리 해제 함수와 소멸자를 암묵적으로 noexcept 로 간주하고 있다. 

 

마지막으로, 함수 구현과 예외 명세 사이의 비일관성을 파악하는데 컴파일러가 별 도움을 주지 않을 수도 있다. 예시를 보자.

void a();
void b();

void c() noexcept
{
  a();
  b();
}

위에서 c 함수는 noexcept 이지만, a 와 b 는 그렇지 않다. 즉, noexcept 함수가 noexcept 보장이 없는 코드에 의존하는 경우가 있다는 뜻이다!

Comments