- noexpcet는 함수의 인터페이스의 일부이다. 이는 호출자가 noexcept 여부에 의존할 수 있음을 뜻한다.
C++98에서 예외 지정은 다소 변덕 스러운 야수였다. 함수 구현이 바뀌면 예외 지정도 바뀔 가능성이 생겼고, 기존의 예외 지정에 의존하던 클라이언트 코드는 깨질 수 있었다.
게다가, 대체로 컴파일러는 함수구현과 게다가, 대체로 컴파일러는 함수 구현과 예외 지정, 그리고 클라이언트 코드 사이의 일관성 유지에 아무런 도움도 주지 않았다. 그래서 대부분의 프로그래머는 결국 C++98의 예외 지정이 득보다 실이 크다고 판단하게 되었다.
C++11 제정 과정에서, 함수의 예외 방출 행동에 관해 정말로 의미 있는 정보는 함수가 예외를 하나라도 던질 수 있는지 아니면 절대로 던지지 않는지의 여부라는 점에 대한 공감대가 형성되었다.
본질적으로 C++98의 것을 대체하는 C++11의 예외 지정에는 바로 그러한 흑백논리가 깔려 있다(C++98 스타일의 예외 지정도 여전히 유효하나, 비권장(deprecate) 기능으로 분류되었다).
C++11에서 함수 선언 시 그 함수가 예외를 방출하지 않을 것임을 명시할 때에는 noexcept라는 키워드를 사용하면 된다.
함수를 noexcept로 선언할 것인지의 여부는 인터페이스 설계상의 문제이다. 함수의 예외 방출 행동은 클라이언트에게 아주 중요한 사항이다. 함수의 호출자는 함수의 noexcept 여부를 조회할 수 있으며, 그 조회 결과는 호출 코드의 예외 안정성이나 효율성에 영향을 미친다.
그런 만큼, 함수의 noexcept 여부는 멤버 함수의 const 여부만큼이나 중요한 정보이다.
- noexcept 함수는 비noexcept 함수보다 최적화의 여지가 크다.
함수 f를 호출했을 때 호출자가 예외를 받게 되는 일이 결코 없음을 약속하고 싶다고 하자. 이를 표현하는 방법은 두 가지이다.
// f는 예외를 방출하지 않음:
// C++98 방식
int f(int x) throw();
// f는 예외를 방출하지 않음:
// 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을 하다 보면 메모리가 부족하여 재할당하는 경우가 생긴다. 재할당이 일어날 때 기존의 메모리에서 새로 할당받은 메모리로 각 요소(예에서는 Widget 객체)가 복사된다.
복사되는 경우 push_back은 강력한 예외 안전성을 보장할 수 있다. 복사가 모두 끝난 후 기존의 메모리에 있던 객체들과 기존의 메모리가 해제되므로, 복사 도중에 예외가 발생해도 std::vector의 상태는 변하지 않는다.
하지만 C++11의 이동 연산을 통해 최적화를 하면 push_back은 강력한 예외 안전성을 보장할 수 없게 된다.
기존 메모리에서 n개의 요소를 이동한 후 (n+1)번째 요소를 이동하는 도중에 예외가 발생하면 push_back 연산이 완료되지 못하고 실패한다. 그런데 원래의 std::vector는 이미 수정된 상태이다.
요소 중 n개가 다른 곳으로 이동했는데, 그것들을 원래대로 복원하는 것은 불가능할 수 있다. 객체를 원래의 메모리로 다시 이동하는 연산 자체에서도 예외가 발생할 수 있기 때문이다.
그래서, 이동 연산들이 예외를 방출하지 않음이 확실하지 않은 한 C++11 컴파일러는 push_back 안의 복사 연산들을 소리없이 이동 연산들로 대체하지 않는다. 이동 연산이 예외를 방출하지 않음이 확실한 경우에는 복사를 이동으로 대체해도 안전하다.
std::vector::push_back 외에도, 표준 라이브러리의 여러 함수는 이러한 "가능하면 이동하되 필요하면 복사한다" 전략을 활용한다. 특히 C++98에서 강한 예외 안전성을 보장하는 다른 함수들이 그런 식으로 작동한다.
그런데 이동 연산이 예외를 방출하지 않음을 함수가 어떻게 알아낼 수 있을까? 답은 명백하다. 주어진 연산이 noexcept로 선언되어 있는지를 점검하면 된다.
std::vector::push_back 같은 함수는 std::move_if_noexcept를 호출하는데, 이것은 타입의 이동 생성자의 noexcept 여부에 따라 우측값으로의 조건부 캐스팅을 수행하는, std::move의 한 변형이다(항목 23 참고).
한편 std::move_if_noexcept 자체는 std::is_nothrow_move_constructible을 점검하는데, 이 타입 특성(항목 9 참고)의 값은 컴파일러가 이동 생성자의 noexcept(또는 throw()) 지정 여부를 보고 설정한다.
noexcept가 특히나 바람직한 또 다른 예로 swap 함수들이 있다. swap은 여러 STL 알고리즘 구현에서 핵심 구성요소이며, 복사 대입 연산자들에서도 흔히 쓰인다. 그러럼 여러 곳에서 쓰이기 때문에, noexcept를 통해서 최적화할 가치가 크다.
흥미롭게도, 표준 라이브러리에 있는 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가 자연스러운 구현이라고 말했음을 주목하기 바란다. 함수를 noexcept로 선언하기 위해 함수의 구현을 작위적으로 비트는 것은 마치 꼬리가 개를 흔드는 격이자, 마차를 말 앞에 두는 격이다.
예외를 방출하는 함수를, 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로 선언되어 있지는 않지만, 실제로는 예외를 절대로 던지지 않을 수도 있다.
이들을 noexcept로 선언하지 않은 데에는 나름의 이유가 있을 것이다.
이를테면, C로 작성된 라이브러리의 일부일 수도 있다.
아니면, C++98의 예외 지정을 사용하지 않기로 결정한, 그리고 C++11에 맞게 갱신되지 는 않은 어떤 C++98 라이브러리의 일부일 수도 있다.
이처럼 noexcept 함수가 적법한 이유로 noexcept 보장이 없는 코드에 의존하는 경우가 있으므로, C++은 이런 코드를 허용하며, 일반적으로 컴파일러는 이에 대해 경고 메시지를 표시하지 않는다.
'PROGRAMMING > Effective Modern C++' 카테고리의 다른 글
| 16. const 멤버 함수를 스레드에 안전하게 작성하라. (0) | 2021.03.09 |
|---|---|
| 15. 가능하면 항상 constexpr을 사용하라. (0) | 2020.12.27 |
| 13. iterator보다 const_iterator를 선호하라. (0) | 2020.12.26 |
| 12. 재정의 함수들을 override로 선언하라. (0) | 2020.12.26 |
| 11. 정의되지 않은 비공개 함수보다 삭제된 함수를 선호하라. (0) | 2020.12.25 |
댓글