7 분 소요

예외 보증 종류

예외에 안전(예외가 발생해도 안전하게 복원되고 계속 동작해도 무방하게 만드는 코드)하려면 다음의 예외 보증이 이루어져야 합니다.

항목 내용
nothrow 보증 절대 예외가 발생하지 않습니다.
상수 멤버 함수이거나, 함수내에서 try-catch()로 예외를 처리한 함수등에서 한정적으로 가능합니다.
기본 보증 예외가 발생해도 적어도 메모리 릭이나 리소스 릭이 없습니다.
스마트 포인터나 Holder 개체를 활용하여 스택 개체로 만들면 쉽게 구현할 수 있습니다.(포인터 멤버 변수 참고)
강한 보증 예외가 발생해도 내부 자료값이 변하지 않고 이전 상태로 복원합니다.
생성자소멸자에서 스마트 포인터나 Holder 개체를 활용하여 기본 보증을 하며(복사 생성자만 지원하는 스마트 포인터 참고), 소멸자에서는 예외를 발생시키지 않습니다. 또한, 복사 대입 연산자는 nothrow swap으로 구현합니다.

클래스 구현의 구체적인 방법은 예외 안전에 좋은 클래스 설계를 참고하시기 바랍니다.

예외 안전에 좋은 함수

  1. 인자리턴값예외 발생스택 풀기에 의해 자동 소멸되도록 합니다.(Holder 참고)
  2. 가정한 값 외에는 인수가 전달되지 않도록 캡슐화 합니다.
  3. 함수내에 사전 가정, 사후 가정을 작성합니다.(공격적 자가진단 참고)

예외와 생성자

다음처럼 일부 멤버 변수만 초기화 하고, 나중에 별도 함수를 호출하여 초기화를 마무리하면, 사용자가 실수로 빼먹을 수도 있고, 예외 보증에도 좋지 않습니다. 불완전하게 생성된 개체에 별도 Setter 함수를 호출하여 완전하게 만드는 중에 예외가 발생하면, 예외 보증 처리를 위해 소멸시켜야 하는데, 혹시나 이미 이 개체를 참조하는 곳이 있다면, 찾기도 힘들고, 찾았더라도 처리를 어찌해야 할지 난감해 지니까요.(예외와 생성자 참고)

다음은 난감해 지는 예입니다.

  1. #1 : const B* other = t.GetB();t개체를 생성해서 사용하고 있는데,
  2. #2 : t.SetA(new A);t개체의 멤버 변수를 마저 설정하려는 순간 예외가 발생한 경우입니다.

other를 사용하는 곳을 추적해서 이전 상태로 복원하는 작업을 해야 합니다만, 어떻게 추적해야 할지 난감하죠.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
class A {
public:
   A() {
      throw "A"; // A 개체를 생성하던 중에 예외가 발생합니다.
   }   
};
class B {
public:
   B() {
      std::cout << "Construct B" << std::endl;
   }   
   ~B() {
      std::cout << "Destruct B" << std::endl;           
   } 
};

class T {
   A* m_A;
   B* m_B;
public:
   explicit T(B* b) : m_B(b) {}
   ~T() {
      delete m_A; // 개체를 소멸합니다.
      delete m_B;
   }
   void SetA(A* a) {m_A = a;} // (△) 비권장. 별도 Setter로 값을 대입합니다.

   const A* GetA() const {return m_A;}
   const B* GetB() const {return m_B;}
};

T t(new B);
const B* other = t.GetB(); // #1. (△) 비권장. 아직 완전하게 생성되지 않은 t개체를 other 포인터로 사용합니다.
.
. // 뭔가 이러저러한 작업을 합니다.
.
t.SetA(new A); // #2 : A개체가 생성되면서 예외를 발생합니다.

// (△) 비권장. t 개체를 소멸해야 합니다.
// (△) 비권장. other 가 사용된 곳을 추적해서 뭔가 예외 처리 작업을 해야 합니다.

따라서, 생성할 때 완전하게 생성해야 합니다.(완전한 생성자 참고) 생성 도중 예외가 발생하면, 생성하던 개체를 버리면 되니까요.

하지만 이 방법도 인수를 임시 생성해서 전달할때 인수가 여러개라면, 어떤 인수가 먼저 생성되었느냐에 따라 복잡해 집니다. 특히 왼쪽에서 오른쪽으로 인수가 생성되어 전달된다고 보장하지 않습니다.(평가 순서 참고)

다음 코드에서 T t(new A, new B);new A 호출 후 new B를 호출한다고 보장하지 않습니다. 어느것이 먼저 호출될지 모릅니다. 컴파일러 마음입니다.

따라서,

  1. new B 생성자 호출
  2. new A 에서 예외 방출

하게 되면, 이미 생성된 B 개체의 소멸자는 호출되지 않습니다. 따라서, 기본 보증을 위반하게 되죠.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
class A {
public:
   A() {
      throw "A"; // A 개체를 생성하던 중에 예외가 발생합니다.
   }
};
class B {
public:
   B() {
      std::cout << "Construct B" << std::endl;
   }   
   ~B() {
      std::cout << "Destruct B" << std::endl;           
   } 
};

class T {
   A* m_A;
   B* m_B;
public:
   T(A* a, B* b) : m_A(a), m_B(b) { 
      std::cout << "Construct T" << std::endl;
   } 
   ~T() {
      delete m_A; // 개체를 소멸합니다.
      delete m_B;
   }
   const A* GetA() const {return m_A;}
   const B* GetB() const {return m_B;}
};

try {
   // A 생성에서 예외가 발생하면 T 개체 생성자 호출 자체가 안됩니다.
   // (△) 비권장. B 개체가 이미 생성되었고, A 개체 생성중 예외가 발생했다면, B 개체는 소멸되지 않습니다.
   // 기본 보증 위반입니다.
   T t(new A, new B); 
   const A* other = t.GetA();
   ...
}
catch (...) {
}

이런 문제 때문에 인수를 임시 생성하여 전달할때, 예외에 안전하게 하려면 스마트 포인터나 Holder 개체를 활용해야 합니다. 다음 코드는 스마트 포인터인 auto_ptr을 사용하며, A에서 예외 발생시 이미 생성된 B 개체도 잘 소멸됩니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
class A {
public:
   A() {
      throw "A"; // A 개체를 생성하던 중에 예외가 발생합니다.
   }
};
class B {
public:
   B() {
      std::cout << "Construct B" << std::endl;
   }   
   ~B() {
      std::cout << "Destruct B" << std::endl;           
   } 
};

class T {
   std::auto_ptr<A> m_A; // 스마트 포인터
   std::auto_ptr<B> m_B;
public:
   T(std::auto_ptr<A> a, std::auto_ptr<B> b) : m_A(a), m_B(b) {  // 소유권 이전
      std::cout << "Construct T" << std::endl;
   } 
   ~T() {
   }
   const A* GetA() const {return m_A.get();}
   const B* GetB() const {return m_B.get();}
};
try {
   // (O) 인자 전달을 위해 인수 생성시 예외가 발생해도 스마트 포인터여서 예외에 안전합니다.
   T t(std::auto_ptr<A>(new A), std::auto_ptr<B>(new B)); 
   const A* other = t.GetA();
   ...
}
catch (...) {
}

예외와 소멸자

소멸자에서 예외를 방출하면 안됩니다. 예외가 발생하면 스택 풀기가 이루어져서 소멸자가 불리는데, 이 소멸자에서 또 예외가 발생하면 정상적인 스택 풀기를 방해합니다.(소멸자에서 예외 방출 금지 참고)

예외 방출을 막기 위해 단순히 try-catch()로 예외를 무시하기 보다는

1
2
3
4
5
6
7
8
9
~T()
{
    try {
       // 예외를 발생시키는 함수
    }
    catch (...) { // 절대 예외 발생 금지
       // 근데, 뭘해야 할지 모릅니다.
   }
}

다음처럼 정리하는 Release() 함수를 별도로 만들어 두고, 만약 사용자가 정리하지 않은 경우 정리 시도를 하는게 좋습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
void T::Release() { // 소멸전 정리하는 함수
   // 에외를 발생시킬수도 있는 정리 코드들

   m_IsReleased = true;
}

T::~T() {
    if (m_IsReleased) return;
    try {
        Release(); // 혹시 안했다면 정리
    } 
    catch (...) { 
        // 여전히 뭘해야 할지 모르겠습니다.
        // 로그 파일 작성 정도를 추천합니다.
    }
}

예외 안전에 좋은 복사 생성자

복사 생성자만 지원하는 스마트 포인터에 언급한 것처럼, 스마트 포인터나 Holder 개체를 활용하여 복사 생성자를 구현하면, 스택 풀기에 의해 기본 보증강한 보증을 하게 됩니다.

예외 안전에 좋은 복사 대입 연산자

swap을 이용한 예외 보증 복사 대입 연산자에 언급한 것처럼, Swap()을 이용하면 예외가 발생한 개체를 버림으로서 기본 보증강한 보증을 하게 됩니다. 단, 이때 Swap()은 예외를 방출하지 말아야 합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class T {
T& operator =(const T& other) {

   // other를 복제한 임시 개체를 만듭니다.
   T temp(other); // (O) 생성시 예외가 발생하더라도 this는 그대로 입니다. 예외 발생시 temp를 버리면 됩니다.

   // this의 내용과 임시 개체의 내용을 바꿔치기 합니다.
   // this는 이제 other를 복제한 값을 가집니다.
   // 여기서 예외를 발생하면 this가 일부 바꿔치기 되어 강한 보증이 안됩니다.
   // 그러니 꼭 nothrow swap으로 구현하세요.
   Swap(temp);

   return *this;

   } // temp는 지역 변수여서 자동으로 소멸됩니다.
};

예외 안전에 좋은 nothrow swap

예외 안전에 좋은 복사 대입 연산자의 언급처럼 Swap()은 예외를 방출하지 말아야 합니다. 만약 예외가 발생한다면, 바꿔치기 하다가 일부만 변경되고 중단된 것이어서 어떻게 복원해야 할지 난감해 집니다. 예외 발생 전의 상태로 완벽하게 복원하려면 Swap()은 절대 예외를 발생시켜서는 안됩니다.

일반적으로 복사 생성이나 복사 대입은 새로운 메모리를 할당하기 때문에 예외가 발생한다고 가정하시는게 좋습니다.

1
2
3
4
5
6
7
8
9
10
11
12
class T {
   U a, b;
   void Swap(T& other) {
      U temp = a; // 예외가 발생할 수 있습니다.
      a = other.a; // 예외가 발생할 수 있습니다.
      other.a = temp; // 예외가 발생할 수 있습니다.

      temp = b; // 예외가 발생할 수 있습니다.
      b = other.b; // 예외가 발생할 수 있습니다.
      other.b = temp; // 예외가 발생할 수 있습니다.
   }
};

Swap()에서 예외를 발생시키지 않으려면, 포인터형 멤버 변수를 1개 만들고, 복사 생성복사 대입을 포인터 연산으로 바꿔서 구현하면 됩니다. 포인터끼리의 대입은 8byte끼리의 복사이므로 복사 부하도 없고, 예외 발생도 없다고 가정하셔도 무방하니까요.(nothrow swap - 포인터 멤버 변수를 이용한 swap 최적화스마트 포인터를 이용한 PImpl 이디엄 구현을 참고하세요.)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
class T {
   struct Impl {
        U a, b;
        Impl(const Impl& other) : 
           a(other.a), b(other.b) {}
    };

    Impl* m_Impl; // 포인터형 멤버 변수 1개입니다.

    T(const T& other) :
       m_Impl(new Impl(other.m_Impl)) {}
    ~T() {delete impl;}

    T& operator =(const T& other) {
      T temp(other);
      Swap(temp); // 절대 예외발생 금지
      return *this;
   }

   void Swap(T& other) {
       Impl* temp = m_Impl; // 포인터끼리 대입입니다. 복사 부하도 없고, 예외 발생도 없다고 가정해도 무방합니다.
       m_Impl = other.m_Impl;
       other.m_Impl = temp;

       // std::swap(m_Impl, other.m_Impl); 로 한방에 할 수도 있음
   }   
};

예외 레이어링

예외를 catch()하지 않으면 전파되어 최종적으로 프로그램이 종료됩니다. 따라서, 대형 프로그램을 개발하거나 라이브러리를 개발하는 경우에, 각 역할별로 모듈을 레이어링 하여 예외가 사용자 모듈로 전파되지 않도록 하는게 좋습니다.

  1. 라이브러리 모듈에서는 예외를 전파합니다.
  2. 모듈의 경계에서 상위 예외 처리 모듈을 둡니다. 해당 모듈에서는 예외를 catch() 한뒤 오류 코드로 변환합니다. 따라서 사용자 모듈로 더이상 예외가 전파되지 않습니다.

image

댓글남기기