728x90
반응형

noexcept는 함수 인터페이스의 일부이다. 이는 호출자가 noexcept 여부에 의존할 수 있음을 뜻한다.

C++98에서 예외 지정은 함수 구현이 바뀌면 예외 지정도 바뀔 가능성이 있었고, 기존의 예외 지정에 의존하던 클라이언트 코드는 깨질 수도 있어 예외 지정이 득보다 실이 크다고 판단되었다.

하지만 C++11에서, 함수의 예외 방출 행동에 관해 정말로 의미 있는 정보는 함수가 예외를 하나라도 던질 수 있는지 아니면 절대로 던지지 않는지의 여부라는 점이라는 의견이 중요해졌는데, 본질적으로 C++98의 것을 대체하는 C++11의 예외 지정에는 바로 그러한 흑백논리가 깔려 있다.(C++98 스타일의 예외 지정도 여전히 유효하나, 비권장(deprecate) 기능으로 분류되었다)
결과적으로, C++11에서 함수 선언 시 그 함수가 예외를 방출하지 않을 것임을 명시할 때에는 noexcept라는 키워드를 사용하면 된다.

함수를 noexcept로 선언할 것인지의 여부는 인터페이스 설계상의 문제이고 함수의 예외 방출 행동은 클라이언트에게 아주 중요한 사항이다. 함수의 호출자는 함수의 noexcept 여부를 조회할 수 있으며, 그 조회 결과는 호출 코드의 예외 안정성이나 효율성에 영향을 미치는 만큼 함수의 noexcept 여부는 멤버 함수의 const 여부만큼이나 중요한 정보이다.

 

함수 f를 호출했을 때 호출자가 예외를 받게 되는 일이 결코 없음을 약속하고 싶다고 할 때, 이를 표현하는 방법은 두 가지이다.

// f는 예외를 방출하지 않음:
// C++98 방식
int f(int x) throw();

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

실행시점에서 어떤 예외가 f 바깥으로 튀어나오면 f의 예외 지정이 위반된다. C++98에서는 예외 지정이 위반되면 호출 스택이 f를 호출한 지점에 도달할 때까지 풀리며(unwind), 그 지점에서 몇 가지 동작이 취해진 후 프로그램 실행이 종료된다.(terminate)
C++11의 예외 지정에서는 실행시점 행동이 약간 다르다. C++11에서는 프로그램 실행이 종료되기 전에 호출 스택이 풀릴 수도 있고 풀리지 않을 수도 있다.

 

호출 스택이 풀리는 것과 풀릴 수도 있는 것의 차이는 컴파일러의 코드 작성에 놀랄 만큼 큰 영향을 미친다.

noexcept 함수에서 컴파일러의 최적화기(optimizer)는 예외가 함수 바깥으로 전파될 수 있다고 해도 실행시점 스택을 풀기 가능 상태로 유지할 필요가 없다. 또한, 예외가 noexcept 함수를 벗어난다고 해도 noexcept 함수 안의 객체들을 반드시 생성의 반대 순서로 파괴해야 하는 것도 아니다.
그러나 예외 지정이 "throw()"인 함수에는 그러한 최적화 유연성이 없으며, 예외 지정이 아예 없는 함수 역시 마찬가지로 그런 유연성이 없다.

 

그리고 noexcept는 이동 연산들과 swap, 메모리 해제 함수들, 그리고 소멸자들에 특히나 유용하다.

std::vector<Widget>을 사용하는 어떤 코드에서, 종종 Widget들을 push_back을 이용해서 벡터에 추가한다고 하자.

push_back을 하다 보면 메모리가 부족하여 재할당 하는 경우가 생기는데, 재할당이 일어날 때 기존 메모리에서 새로 할당 받은 메모리로 각 요소가 복사된다.

복사되는 경우 push_back은 강력한 예외 안정성을 보장할 수 있다. 복사가 끝난 후에는 기존 메모리의 객체들과  메모리가 해제 되므로, 복사 도중에 예외가 발생해도 std::vector의 상태는 변하지 않는다.

하지만 C++11의 이동 연산을 통해 최적화를 하면 push_back은 강력한 예외 안정성을 보장할 수 없게 된다.

기존 메모리에서 n개의 요소를 이동한 수 (n+1)번째 요소를 이동하는 도중에 예외가 발생하면 push_back연산이 완료되지 못하고 실패하는데, 원래의 std::vector는 이미 수정된 상태이기 때문이다.

그래서, 이동 연산들이 예외를 방출하지 않음이 확실하지 않는 한 C++11 컴파일러는 push_back 안의 복사 연산자들을 소리없이 이동 연산들로 대체하지 않는다. 이동 연산이 예외를 방출하지 않음이 확실한 경우에는 복사를 이동으로 대체해도 안전하다.

그래서 표준 라이브러리의 여러 함수는 이러한 "가능하면 이동하되 필요하면 복사한다" 전략을 활용한다.

여기서 이동 연산이 예외를 방출하지 않음을 함수가 알아내기 위해서 컴파일러는 주어진 연산이 noexcept로 선언되어 있는지를 점검한다.

noexcept가 바람직한 또 다른 예로 swap함수들이 있는데, 특이한 점은 표준 라이브러리에 있는 swap들의 noexcept여부는 사용자 정의 swap들의 noexcept여부에 어느정도 의존한다.

// 예를 들어 다음은 표준 라이브러리에 있는 배열에 대한 swap과 std::pair에 대한 swap의 선언들이다.
 
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이다. 
// 즉, 이들이 noexcept인지의 여부는 noexcept절 안의 표현식들이 noexcept인지에 의존한다.
// 예를 들어 Widget 배열들에 대한 swap은 Widget들에 대한 swap이 noexcept일 때에만 noexcept인 것이다.
// 따라서, Widget 배열들에 대한 swap이 noexcept인지는 Widget을 위한 swap을 작성한 프로그래머가 결정한다.

 

noexcept로 선언하는 것이 아주 중요한 일부 함수들은 기본적으로 noexcept로 선언된다. 기본적으로 모든 메모리 해제 함수(operator delete와 operator delete[] 등)와 모든 소멸자는 암묵적으로 noexcept이다.
따라서 그런 함수들은 직접 noexcept로 선언할 필요가 없다.

소멸자가 암묵적으로 noexcept로 선언되지 않는 유일한 경우는, 예외 방출 가능성을 명시적으로 밝힌(즉, noexcept(false)로 선언된) 소멸자를 가진 타입의 데이터 멤버가 클래스에 있을 때 뿐이다.
그런 소멸자들은 흔치 않으며, 라이브러리가 사용하는 어떤 객체의 소멸자가 예외를 방출하면, 프로그램의 행동은 정의되지 않는다.

 

그리고 대부분의 함수는 noexcept가 아니라 예외에 중립적이다.

noexcept는 함수의 인터페이스의 일부이기 때문에 함수의 구현이 예외를 방출하지 않는다는 성질을 오랫동안 유지할 결심이 선 경우에만 함수를 noexcept로 선언해야 한다.
만일 함수를 noexcept로 선언하고는 나중에 마음을 바꾼다면, 함수의 인터페이스가 변경(noexcept를 제거)되는 내용이기 때문에 클라이언트 코드가 깨질 위험이 생긴다.

중요한 것은, 대부분의 함수가 예외에 중립적(exception-neutral)이라는 점이고 예외 중립적 함수는 스스로 예외를 던지지는 않지만, 예외를 던지는 다른 함수들을 호출할 수는 있는 점이다.
다른 함수가 예외를 던지면 예외 중립적 함수는 그 예외를 그대로 통과시키는데, 이처럼 통과하는 예외가 있을 수 있으므로 예외 중립적 함수는 결코 noexcept가 될 수 없다.

그러나, 예외를 전혀 방출하지 않는 것이 자연스러운 구현인 함수들도 있으며, noexcept로 선언하면 최적화에 큰 도움이 되는 함수들도 많다(특히 이동 연산들과 swap). 어떤 함수가 예외를 방출하지 않는다는 점을 확신할 수 있다면, 당연히 noexcept로 선언해야 한다.

noexcept를 언제 사용해야할 지에 대한 구분으로 넓은 계약(wide contract)들을 가진 함수와 좁은 계약(narrow contract)들을 가진 함수를 구분해서 넓은 계약을 가진 함수들에 대해서만 noexcept를 사용하는 경향을 가진 프로그래머들도 있다.

넓은 계약을 가진 함수는 전제조건이 없는 함수를 말하며 프로그램의 상태와 무관하게 호출할 수 있고 호출자가 전달하는 인수들에 그 어떤 제약도 가하지 않는다. 넓은 계약 함수는 결코 미정의 행동을 보이지 않는다.
좁은 계약을 가진 함수는 넓은 계약을 가진 함수가 아닌 함수들이다. 그런 함수의 경우 함수의 전제조건이 위반되면 그 결과는 미정의 행동이 나온다.

넓은 계약을 가진 함수를 작성하는 경우 만일 그 함수가 예외를 던지지 않음을 알고 있다면 이 항목의 조언을 따라 함수를 noexcept로 선언하는 것이 좋고, 좁은 계약을 가진 함수는 함수 내에서 전제조건을 위반하였음을 알리는 예외를 던질 가능성이 있기 때문에 던져진 예외를 디버깅하는 것이 미정의 행동의 원인을 추적하는 것보다 쉽기에 noexcept를 선언하지 않는다.


마지막 요점으로, C++11에서도 함수 구현과 예외 지정 사이의 비일관성을 파악하는 데 컴파일러가 별 도움을 주지 않는다.

// 다음의 코드는 완벽히 적법한 코드이다.
// 다른 어딘가에 정의된 함수들
void setup(void);
void cleanup(void);
 
void doWork(void) noexcept
{
    setup(); // 필요한 준비 작업을 수행
    ... // 실제 작업을 수행
    cleanup(); // 정리 작업을 수행
}

// 여기서 doWork는 비noexcept 함수 setup과 cleanup을 호출함에도 noexcept로 선언되어 있다.
// 이는 모순된 일로 보이지만, 어쩌면 그냥 문서화의 문제일 수도 있다.
// setup과 cleanup이 비록 noexcept로 선언되어 있지는 않지만, 실제로는 예외를 절대로 던지지 않을 수도 있고, C로 작성된 라이브러리의 일부일 수도 있다.
// 아니면, C++98의 예외 지정을 사용하지 않기로 결정한, 그리고 C++11에 맞게 갱신되지 않는 어떤 C++98 라이브러리의 일부일 수도 있다.
// 이처럼 noexcept 함수가 적법한 이유로 noexcept 보장이 없는 코드에 의존하는 경우가 있으므로, C++은 이런 코드를 허용하며, 일반적으로 컴파일러는 이에 대해 경고 메시지를 표시하지 않는다.
728x90
반응형