본문 바로가기

컴퓨터과학

[Effective C++] (14-4) 예외를 방출하지 않을 함수는 noexcept로 선언할 것

C++ noexception

앞에서 어떤 함수들은 noexcept가 자연스러운 구현이라고 말했음을 주목하기 바랍니다. 함수를 noexcept로 선언하기 위해 하마수의 구현을 작위적으로 비트는 것은 마치 꼬리가 개를 흔드는 격이자, 마차를 말 앞에 두는 격이자, 나무만 보고 숲을 보지 않는 격이다. 싱거운 비유는 그만하고, 함수의 직접적인 구현이 예외를 던질 수 있다고 할 때(이를테면 예외를 던질 수 있는 함수를 호출하기 때문에), 그 사실을 호출자에게 숨기기 위해 구현을 억지로 고치면(이를테면 모든 예외를 잡고 그것들을 상태 부호(status code)나 특별한 반환 값으로 대체해서) 함수의 구현이 복잡해질 뿐만 아니라 호출 지점의 코드도 복잡해질 가능성이 큽니다. 예를 들어 호출자는 상태 부호나 특별한 반환 값을 점검해야 합니다. 그런 복잡성이 유발하는 실행 시점 비용(이를테면 분기가 많아진다거나, 함수가 커져서 명령 캐시를 더욱 압박하는 등)은 noexcept를 통해서 가능한 최적화가 주는 성능 향상을 능가할 수 있습니다. 게다가 소스 코드를 이해하고 유지 보수하기도 어려워집니다. 이를 두고 훌륭한 소프트웨어 공학이라고 하기는 힘들 것입니다.

 

noexcept로 선언하는 것이 아주 중요한 일부 함수들은 기본적으로 noexcept로 선언됩니다. C++98에서는 메모리 해제 함수들(즉, operator delete와 operator delete[])이 예외를 방출하는 것이 나쁜 코딩 스타일로 간주되었으며, C++11에서는 그러한 스타일 규칙의 대부분이 언어 차원의 규칙으로 승격되었습니다. 기본적으로 모든 메모리 해제 함수와 모든 소멸자(사용자 정의 소멸자이든, 컴파일러가 자동으로 작성하는 것이든)는 암묵적으로 noexcept입니다. 따라서 그런 함수들은 직접 noexcept로 선언할 필요가 없습니다. (직접 선언해도 해가 되지는 않습니다. 단지 관례에서 벗어나는 일일 뿐입니다.) 소멸자가 암묵적으로 noexcept로 선언되지 않는 유일한 경우는, 예외 방출 가능성을 명시적으로 밝힌(즉, noexcept(false)로 선언된) 소멸자를 가진 형식의 자료 멤버가 클래스에 있을 때뿐입니다. 그런 소멸자들은 흔치 않습니다. 표준 라이브러리에는 하나도 없으며, 표준 라이브러리가 사용하는 어떤 객체(이를테면 컨테이너에 담긴 객체나 알고리즘에 전달된 객체)의 소멸자가 예외를 방출하면, 프로그램의 행동은 정의되지 않습니다.

 

라이브러리 인터페이스 설계자들 중에는 소위 넓은 계약(wide contract)들을 가진 함수와 좁은 계약(narrow contract)들을 가진 함수를 구분하는 사람들이 있습니다. 넓은 계약을 가진 함수는 전제조건이 없는 함수를 말합니다. 그런 함수는 프로그램의 상테와는 무관하게 호출할 수 있으며, 호출자가 전달하는 인수들에 그 어떤 제약도 가하지 않습니다. 넓은 계약 함수는 결코 미정의 행동을 모이지 않습니다.

 

(* "프로그램의 상태와 무관"하고 "그 어떤 제약도 가하지 않는"다고 해서, 이미 미정의 행동을 보이는 프로그램이 유효해지는 것은 아닙니다. 예를 들어 std::vector::size는 넓은 계약을 가지고 있지만, 그렇다고 해서 임의의 메모리 조각을 std::vector로 캐스팅해서 그 함수를 호출하는 것이 합당한 일은 아닙니다. 그러한 캐스팅의 결과는 미정의 행동이며, 따라서 그러한 캐스팅을 포함한 프로그램의 행동에 대해서는 어떠한 보장도 없습니다.)

 

넓은 계약을 가진 함수가 아닌 함수들은 모두 좁은 계약을 가진 함수입니다. 그런 함수의 경우 함수의 전제조건이 위반되면 그 결과는 미정의 행동입니다.
넓은 계약을 가진 함수를 작성하는 경우, 만일 그 함수가 예외를 던지지 않음을 알고 있다면 이 항목의 조언을 따라 함수를 noexcept로 선언하는 것은 쉬운 일입니다. 좁은 계약을 가진 함수에 대해서는 상황이 좀 더 까다롭습니다. 예를 들어 std::string 매개변수를 받는 f라는 함수를 작성하는데, 그 f의 자연스러운 구현이 결코 예외를 방출하지 않는다고 합시다. 그렇다면 f를 noexcept로 선언해야 마땅합니다.

 

그런데 f에, std::string 매개변수의 길이가 32자를 넘지 않아야 한다는 전제 조건이 있다고 합시다. 길이가 32를 넘는 std:;string으로 f를 호출한 결과는 미정의 행동에 해당합니다. 정의에 의해, 전제조건 위반의 결과는 곧 미정의 행동이기 때문입니다. f에게는 그러한 전제조건을 점검해야 할 의무가 없습니다. 함수가 자신의 전제조건이 만족되리라고 가정하는 것은 합당한 일이기 때문입니다. (전제조건들이 유효한지 확인하는 것은 호출자의 책임입니다.) 그렇다면, 비록 전제조건이 있다고 해도, f를 noexcept로 선언하는 것은 합당한 일로 보입니다.

 

void f(const std::string& s) noexcept; // 전제조건:
// s.length() <= 32

 

그런데 f의 구현자가 전제조건 위반을 f에서 직접 점검하기로 했다고 합시다. 전제조건 점검이 필수는 아니지만 금지된 것도 아니며, 때에 따라서는(이를테면 시스템 검사 도중에) 유용할 수도 있습니다. 일반적으로, 던져진 예외를 디버깅하는 것이 미정의 행동의 원인을 추적하는 것보다 더 쉽습니다. 그런데 전제조건 위반을 검사 설비(test harness)나 클라이언트의 오류 처리부가 검출할 수 있도록 보고 하려면 어떻게 해야 할까요?? 직접적인 접근방식 하나는 "전제조건이 위반되었음"을 나타내는 예외를 던지는 것이지만, f는 noexcept로 선언되어 있으므로 그런 방법은 불가능할 수 있습니다. 예외를 던지면 프로그램이 종료될 것이기 때문입니다. 이런 이유로, 넓은 계약과 좁은 계약을 구분하는 라이브러리 설계자들은 넓은 계약을 가진 함수들에 대해서만 noexcept를 사용하는 경향이 있습니다.
마지막 요점으로, 이 항목의 도입부에서 함수 구현과 예외 명세 사이의 비일관성을 파악하는 데 컴파일러가 별 도움을 주지 않는다는 나의 주장을 좀 더 해명해 보겠습니다. 다음과 같이 완벽히 적법한 코드를 생각해 봅시다.

 

void setup(); // 다른 어딘가에 정의된 함수들
void cleanup();

void doWork() noexcept
{
 setup(); // 필요한 준비 작업을 수행
 ... // 실제 작업을 수행
 cleanup(); // 정리 작업을 수행
}

 

여기서 doWork는 비noexcept 함수 setup과 cleanup을 호출함에도 noexcept로 선언되어 있습니다. 이는 모순된 일로 보이지만, 어쩌면 그냥 문서화의 문제일 수도 있습니다. 즉, setup과 leanup이 비록 noexcept로 선언되어 있지만 않지만, 실제로는 예외를 절대로 던지지 않을 수도 있습니다. 이들을 noexcept로 선언하지 않은 데에는 나름의 이유가 있을 것입니다. 이를테면 C로 작성된 라이브러리의 일부일 수도 있습니다. (std 이름 공간으로 옮겨진 C 표준 라이브러리의 함수들조차도 예외 명세가 빠져 있습니다. 예를 들어 std::strlen은 noexcept로 선언되어 있지 않습니다.) 아니면, C++98의 예외 명세를 사용하지 않기로 결정한, 그리고 C++11에 맞게 갱신되지 는 않은 어떤 C++98 라이브러리의 일부일 수도 있습니다.
이처럼 noexcept 함수가 적법한 이유로 noexcept 보장이 없는 코드에 의존하는 경우가 있으므로, C++은 이런 코드를 허용하며, 일반적으로 컴파일러는 이에 대해 경고 메시지를 표시하지 않습니다.

 


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