ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [Effective Modern C++] noexcept
    C++/Effective Modern C++ 2022. 4. 28. 11:35

    예외 처리에 대한 기초적인 내용은 다른 글 (예외 처리)에 간략히 정리되어 있습니다.

    함수의 예외 방출 여부는 매우 중요한 사항입니다.

    클라이언트(사용자) 입장에서는 기존의 코드를 바꾸어야 할 수도 있고, 컴파일러의 입장에서는 최적화와 관련이 크기 때문입니다.

    이렇듯 예외처리에서 가장 중요한것은 예외가 발생할 수 있는지 아닌지의 정보입니다.

    이분법적인 정보가 중요한 만큼, C++11에서는 예외를 방출하지 않는 선언을 하는 noexcept 키워드가 추가되었습니다.

     


     

    1. noexcept와 Stack unwind

     

    더보기

    함수에서 예외를 방출하지 않는다는 선언은 다음과 같습니다.

    //C++98 Style
    void function() throw();
    
    //C++11 Style
    void function() noexcept;

    위와 같이 선언한 함수 function은 예외를 발생시키지 않는 것이 아닙니다.

    함수가 예외를 발생시키지 않을 것이라고 개발자가 선언 하는 것이므로, 위 함수들에서도 예외가 발생할 수 있습니다.

     

    위 두 선언 (throw와 noexcept)는 예외가 실제로 발생했을 때의 행동이 조금 다릅니다.

    throw의 경우 호출 스택이 호출자에게 도달할 때 까지 풀리게 되는 반면,

    noexcept의 경우 스택이 풀릴 수도, 풀리지 않을 수도 있습니다.

    두 선언 모두 호출자의 나머지 코드가 실행되는 일 없이 프로그램이 종료되는 것은 동일합니다.

     

    하지만 Stack unwind가 수행되는지의 여부는 매우 큰 차이가 존재합니다.

    noexcept함수는 호출 스택을 실행 시점까지 풀어낼 필요가 없습니다.

    또한, noexcept함수 내부의 객체들을 생성 순서의 반대로 소멸할 필요도 없습니다.

    throw()로 예외를 없앤 함수 또한 마찬가지지만, 컴파일러의 Optimizer의 대응이 두 경우에서 다르기 때문에, 예외를 방출하지 않는 함수의 선언에는 throw()보다 noexcept가 적합합니다.

     

    2. noexcept와 STL

     

    더보기

    일부 STL에서도 함수들의 noexcept 여부가 매우 중요하게 작동합니다.

    예제를 살펴보도록 하겠습니다.

    #include <iostream>
    using namespace std;
    
    int cp = 0;
    int mv = 0;
    int co = 0;
    
    class MyClass {
    public:
        MyClass(int val) : val(val) {
            co++;
        }
        MyClass(const MyClass& rhs) : val(rhs.val) {
            cp++;
        }
        MyClass(const MyClass&& rhs) : val(rhs.val) {
            mv++;
        }
    
    private:
        int val;
    };
    
    int main() {
        std::vector<MyClass> vec;
    	
        for (int i = 0; i < 100; i++) {
            vec.push_back(MyClass(i));
        }
    
        cout << co << " " << cp << " " << mv << " \n";
    }
    100 284 100

    std::vector에 MyClass를 100개 삽입하고, 그 과정에서 생성자, 복사 생성자, 이동 생성자가 몇 번 호출되었는지 확인하는 코드입니다.

    다음의 예제와 함께 살펴보도록 하겠습니다.

    #include <iostream>
    using namespace std;
    
    int cp = 0;
    int mv = 0;
    int co = 0;
    
    class MyClass {
    public:
        MyClass(int val) : val(val) {
            co++;
        }
        MyClass(const MyClass& rhs) : val(rhs.val) {
            cp++;
        }
        MyClass(const MyClass&& rhs) noexcept : val(rhs.val) {
            mv++;
        }
    
    private:
        int val;
    };
    
    int main() {
        std::vector<MyClass> vec;
    	
        for (int i = 0; i < 100; i++) {
            vec.push_back(MyClass(i));
        }
    
        cout << co << " " << cp << " " << mv << " \n";
    }
    100 0 384

    두 코드의 차이점은, 이동 생성자의 noexcept여부 단 한가지 입니다.

    이동 생성자가 noexcept로 선언된 것으로 복사 생성자가 호출되지 않은 것을 볼 수 있습니다.

     

    std::vector는 새 요소를 삽입할 때 크기(size)가 부족할 수 있습니다. (size = capacity의 상황)

    이 경우 std::vector는 capacity(용량)를 다른 메모리 조각에 새로 할당하고, 요소들을 그곳으로 옮기는 방식으로 용량을 확장합니다.

    C++98에서는 이것을 복사 생성자를 통해 수행하고 기존 요소들을 삭제했습니다.

    이러한 접근 방식으로 인해 std::vector의 삽입 연산은 강한 예외 안전성을 보장합니다.

     

    하지만 위와 같은 방식은 C++11의 이동 시맨틱이 등장하면서 비 효율적인 과정이 되었습니다.

    vector의 요소를 복사하지 않고, 이동하기만 하면 성능이 향상될 수 있기 때문입니다.

    하지만 만약 이동 도중 예외가 발생할 경우 vector의 상태가 변할 수 있습니다.

    기존 코드가 예외 안전성에 대한 의존도가 컸을 경우, 이것은 문제가 될 수 있습니다.

     

    이러한 이유 때문에 C++11의 여러 STL (ex. std::vector::push_back, reverse, std::deque::inser 등...)들은 곧바로 이동 생성자를 사용하지 않고, std::move_if_noexcept라는 함수를 통해 이동 생성자의 noexcept여부에 따라 선택적으로 이동 생성자를 사용합니다.

    위 예제 코드의 noexcept여부에 따라 이동 생성자, 복사 생성자의 호출 여부가 갈리는 것이 이러한 이유 때문입니다.

     

    3. noexcept의 의존성

     

    더보기

    일부 함수는 noexcept여부가 그것이 사용하는 하위 함수들이 noexcept인지에 의존적

     

    개별 함수의 noexcept 여부가, 그 함수에서 호출하는 다른 객체, 혹은 함수에 의존적인 함수가 존재합니다.

    std::swap이 대표적인 예시로, 다음은 그 선언 중 일부입니다.

    //std::swap
    template <class T, size_t N>
    void swap(T (&a)[N], T (&b)[N]) noexcept(noexcept(swap(*a, *b));
    
    //std::pair<>::swap
    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 함수입니다.

    std::swap의 경우, T배열을 교환하는 swap의 noexcept여부는 T를 교환하는 swap의 noexcept여부에 따라 결정됩니다.

    std::pair<>도 마찬가지로, pair 내부에 있는 두 객체 T1, T2각각을 교환하는 swap의 noexcept여부에 따라 std::pair<>::swap의 noexcept여부가 결정됩니다.

     

    위와 같이, 계층적인 자료구조 (단일 객체, 배열, 이차원 배열 ... ) 에 대한 함수의 경우, 하위 자료구조에 대한 연산의 noexcept여부가 상위 자료구조의 noexcept여부를 결정하게 될 수도 있습니다.

     

    4. noexcept를 사용하기 전에

     

    더보기

    위 문단들에서 살펴본 바에 의하면 noexcept를 사용할 경우 예외 상황 발생 시 Stack unwind의 수행에 있어서 이점이 생기거나, 복사 연산을 이동 연산으로 대체할 수 있다는 점에서 이점이 생깁니다.

    전반적으로 프로그램의 최적화에 있어서 이점을 주는 noexcept연산자 이지만, 사용에 주의를 기해야 합니다.

     

    • 우선, noexcept는 함수의 인터페이스의 일부입니다. 

    함수의 서명을 설계할 때, 수정할 때의 고려대상으로 noexcept 또한 고려대상이 됩니다.

    따라서 함수가 예외를 방출하지 않는다는 속성이 장기간 유지될 수 있는 확신이 있을 때에만 noexcept를 사용하는 것이 좋습니다.

     

    • 그리고, 함수의 구현은 자연스러워야 합니다.

    noexcept로 얻게 되는 이득이 크더라도, 그것을 위해 함수의 구현 방식을 비트는 것은 좋지 않습니다.

    예를 들어, 특정 예외를 발생시키지 않기 위해 Status code를 반환하거나, 분기가 많아짐으로 인해 비용이 커지거나 하는 등의 설계 방향은 noexcept로 얻게 되는 이득보다 손해가 더 클 수 있습니다.

    프로그램의 성능 외적인 측면에서는, 코드의 가독성이 좋지 않아지거나 Status code의 경우에는 사용자가, 혹은 다른 개발자가 이에 대해 숙지해야 하므로 개발 효율이 떨어질 수 있습니다.

     

    • 대부분의 함수는 예외에 중립적 입니다.

    이 말은 예외를 발생시키지는 않지만, 예외를 발생시키는 함수를 호출할 수 있고, 예외를 받으면 그것을 그대로 전달한다는 것 입니다. 

    예외를 받아서 다른 곳으로 (상위 스코프, 스택 등) 전달하는 특성이 있기 때문에, noexcept로 선언할 수 없습니다.

    대부분의 함수가 noexcept로 선언되지 않는 이유입니다.

     

    • noexcept로 선언되는 것이 중요한 함수들은 이미 noexcept일 수 있습니다.

    예를 들어, 메모리 해제와 관련된 기능들인 operator delete, operator delete[], 클래스 소멸자가 있습니다.

    이러한 함수들은 암묵적으로 noexcept입니다.

    예외적으로, 클래스가 "noexcept(false)로 선언된 (예외를 방출할 수도 있는)소멸자를 가진 객체"를 멤버로 가지고 있을 경우, 그 클래스의 소멸자가 noexcept가 되기 위해서는 명시적으로 선언해야 합니다.

     

    5. 넓은 계약(Wide contract), 좁은 계약(Narrow contract)

     

    더보기

    함수를 구분하는 방법 중에는 넓은 계약 (Wide contract), 좁은 계약 (Narrow contract)로 구분하는 방법이 존재합니다.

    넓은 계약이란 함수의 전제조건이 없는 함수를 뜻합니다.

    좁은 계약은 넓은 계약이 아닌 다른 함수들을 의미합니다.

    두 종류의 함수에 대하여 noexcept를 적용하려 할 때, 넓은 계약 함수는 함수의 로직 상 예외가 발생하지 않을 경우 noexcept로 선언할 수 있습니다.

    하지만 좁은 계약 함수의 경우 생각 해 볼 내용이 조금 더 있습니다. 좁은 계약 함수는 전제조건이 있으며, 이 전제조건이 함수 내부에서 점검을 해야 할 이유가 없을 수도 있기 때문입니다.

    전제조건을 만족할 경우 예외를 방출하지 않음이 보장된다고 하더라도, 전제조건을 점검하거나, 디버깅을 용도로 미정의 행동을 유발시키는 상황 또한 존재합니다.

    이런 경우 noexcept가 선언되어 있을 경우 프로그램이 종료되기 때문에 디버깅이 난해할 수 있습니다.

     

    위와 같은 이유로, 좁은 계약 함수에는 noexcept를 사용하지 않는 경향이 있다고 합니다.

     

    6. 컴파일러와 noexcept

     

    더보기

    noexcept함수에서 noexcept가 아닌 함수를 호출하더라도 컴파일러는 경고하지 않는다.

    noexcept가 선언되지 않았더라도 예외를 발생시키지 않을 수 있고, 예외 명세가 빠져있는 것일 수도 있기 때문이다.

     

    noexcept를 사용하는데에 컴파일러는 도움이 되지 않을 확률이 높습니다.

    예제를 살펴보도록 하겠습니다.

    void function1();
    void function2();
    
    void function3() noexcept {
        function1();
        ...
        function2();
    }

    function1, function2는 noexcept로 선언되지 않았습니다.

    하지만 1, 2를 호출한 function3는 noexcept입니다.

    이 경우, 컴파일러는 어떤 보고도 하지 않습니다.

    function1, 2는 예외 명세가 작성되어있지 않지만 예외를 방출하지 않는 함수일수도 있고, 예외 명세가 빠진 함수일 수도 있습니다.

    위와 같이 기술적이지 않은 이유로 noexcept함수가 noexcept가 보장되지 않은 함수에 의존하는 경우도 종종 존재하므로, 컴파일러는 위와 같이 noexcept가 혼용된 함수를 허용합니다.

     


     

    본문의 내용처럼 대부분의 함수는 예외에 중립적입니다.

    예외를 처리하는 구문이 없더라도, 그것을 자신의 호출자에게 넘기는 경우가 많습니다.

    그에 비해 noexcept 함수는 호출자에게 예외를 전달하지 않습니다.

    컴파일러 레벨에서는 별다른 보고를 하지 않는 예약어이지만, 기능상으로는 최적화의 여지가 큰 예약어라고 생각합니다.

    물론 과도한 사용으로 주객이 전도되는 일은 없어야 할 것입니다.

     

    감사합니다.

    댓글

Designed by Tistory.