KoreanFoodie's Study

Effective C++ | 항목 29 : 예외 안전성이 확보되는 그날 위해 싸우고 또 싸우자! 본문

Tutorials/C++ : Advanced

Effective C++ | 항목 29 : 예외 안전성이 확보되는 그날 위해 싸우고 또 싸우자!

GoldGiver 2022. 10. 25. 16:25

C++ 프로그래머의 필독서이자 바이블인, 스콧 마이어스의 Modern Effective C++ 를 읽고 기억할 내용을 요약하고 있습니다. 꼭 읽어보시길 추천드립니다!

항목 29 : 예외 안전성이 확보되는 그날 위해 싸우고 또 싸우자!

핵심 :

1. 예외 안전성을 갖춘 함수는 실행 중 예외가 발생되더라도 자원을 누출시키지 않으며 자료구조를 더럽힌 채로 내버려 두지 않는다. 이런 함수들이 제공할 수 있는 예외 안전성 보장은 기본적인 보장, 강력한 보장, 예외 금지 보장이 있다.
2. 강력한 예외 안전성 보장은 '복사-후-맞바꾸기' 방법을 써서 구현할 수 있지만, 모든 함수에 대해 강력한 보장이 실용적인 것은 아니다.
3. 어떤 함수가 제공하는 예외 안전성 보장의 강도는, 그 함수가 내부적으로 호출하는 함수들이 제공하는 가장 약한 보장을 넘지 않는다.


예외 안전성이라는 측면에서 다음 코드를 살펴보자.

class PrettyMenu
{
  public:
    ...
    // 배경 그림을 바꾸는 멤버 함수
    void changeBackground(std::istream* imgSrc);
    ...
  
  private:
    Mutex mutex;
    Image *bgImage; // 현재의 배경 그림
    int imgaeChanges; // 배경 그림이 바뀐 횟수
};

void PrettyMenu::changeBackground(std::istream& imgSrc)
{
  lock(&mutex);
  
  delete bgImage;
  ++imgaeChanges;
  bgImage = new Image(imgSrc);
  
  unlock(&mutex);
}

위 코드는 예외 안전성 원칙을 전혀 지키지 않는 위험천만한 함수이다. 예외 안전성을 가진 함수라면 예외가 발생할 때 이렇게 동작해야 한다.
자원이 새도록 만들지 않는다 : 위의 코드는 자원이 샌다. 왜냐하면 "new Imgae(imgSrc)" 에서 예외를 던지면 unlock 함수가 실행되지 않아 뮤텍스가 계속 잡힌 상태로 남기 때문이다.
자료구조가 더럽혀지는 것을 허용하지 않는다 : "new Imgae(imgSrc)" 에서 예외를 던지면 그림은 이미 삭제된 상태고, imageChanges 변수는 이미 증가해 버린 채로 남는다.
위의 코드에서 자원 누출 문제는 항목 13 항목 14를 참고하면 다음과 같이 고칠 수 있다.

void PrettyMenu::changeBackground(std::istream& imgSrc)
{
  // 뮤텍스를 대신 획득하고 필요없어지면 바로 해제
  Lock ml(&mutex);
  
  delete bgImage;
  ++imgaeChanges;
  bgImage = new Image(imgSrc);
}


이제 자료구조 오염 문제를 해결해 보자. 예외 안전성을 갖춘 함수는 아래의 세 가지 보장(guarantee) 중 하나를 제공한다.

  • 기본적인 보장(basic guarantee) : 함수 동작 중에 예외가 발생하면, 실행 중인 프로그램에 관련된 모든 것들을 유효한 상태로 유지하겠다는 보장이다. 위의 예시에서는, 예외가 발생했을 때 이전 그림을 유지할 수도, 기본 배경그림을 사용할 수도 있을 것이다(프로그래머에게 달림).
  • 강력한 보장(strong guarantee) : 함수 동작 중에 예외가 발생하면, 프로그램의 상태를 절대로 변경하지 않겠다는 보장이다. 이런 함수를 호출하는 것은 원자적인(atomic) 동작이라고 할 수 있다. 즉, 함수가 성공적으로 실행을 마친 후의 상태와 함수가 호출될 때의 상태만이 존재할 수 있다.
  • 예외불가 보장(nothrow guarantee) : 예외를 절대로 던지지 않겠다는 보장이다. 약속한 동작은 언제나 끝까지 완수하는 함수라는 뜻이다. 기본제공 타입(int, 포인터 등) 에 대한 모든 연산은 예외를 던지지 않게 되어 있다.

어떠 예외도 던지지 않게끔 예외 지정이 된 함수는 예외불가 보장을 제공한다고 생각한다면, 잘못 생각한 것이다. 아래 예시를 보자.

// 비어 있는 예외 지정
int doSomething() throw();

위의 함수 선언이 전하는 메시지는 doSomething 이 절대로 예외를 던지지 않겠다는 말이 아니다. 만약 doSomething 에서 예외가 발생되면 매우 심각한 에러가 생긴 것으로 판단되므로, 지정되지 않은 예외가 발생했을 경우에 실행되는 처리자인 unexpected 함수가 호출된다.
앞서 언급했듯, 예외 안전성을 갖춘 함수는 위의 세 가지 보장 중 하나를 반드시 제공해야 한다. 일반적으로는 기본적인 보장과 강력한 보장 중 하나를 고르게 된다.
강력한 보장을 거의 적용한 예시를 보자.

class PrettyMenu
{
  ...
  std::shared_ptr<Image> bgImage;
  ...
};

void PrettyMenu::changeBackground(std::istream& imgSrc)
{
  Lock m1(&mutex);
  // bgImage 의 내부 포인터를 "new Image" 의 표현식의 실행 결과로 바꿔치기
  bgImage.reset(new Image(imgSrc));
  
  ++imageChanges;
}

delete 연산자는 reset 함 수 안에 들어가 있으므로, reset 이 불리지 않는 한 delete 도 쓰일 일이 없다. 물론, new Image 가 예외를 일으킬 때, 입력 스트림의 읽기 표시자가 이동한 채로 남아 있을 가능성이 있다. 따라서 엄밀히 말하면 위의 코드는 기본적인 보장을 제공한다고 볼 수도 있다.

추가적으로, '복사-후-맞바꾸기(copy-and-swap)' 전략을 사용하면 강력한 예외 안전성 보장을 제공하도록 만들 수 있다. 'pimpl 관용구' 를 사용하는 다음 예시를 보자.

struct PMImpl
{
  std::shared_ptr<Image> bgImage;
  int imageChanges;
};

class PrettyMenu
{
  ...
  private:
    Mutex mutex;
    std::shared_ptr<PMImpl> pImpl;
};

void PrettyMenu::changeBackground(std::istream& imgSrc)
{
  // 항목 25 참고
  using std::swap;
  
  // 뮤텍스를 잡는다
  Lock m1(&mutex);

  // 객체의 데이터 부분을 복사한다
  std::shared_ptr<PMImpl> pNew(new PMImpl(*pImpl));
  
  // 사본을 수정한다
  pNew->bgImage.reset(new Image(imgSrc));
  ++pNew->imgaeChanges;
  
  // 배경 그림을 바꿔치기한다
  swap(pImpl, pNew);

  // 뮤텍스를 놓는다
}

PMImpl 은 구조체가 아니라 클래스로 만들어도 무방하지만, PrettyMenu 클래스에서 private 으로 선언했으니 캡슐화에 있어 큰 문제는 없다.
'복사-후-맞바꾸기' 전략은 객체의 상태를 '전부 바꾸거나 혹은 안 바꾸거나(all-or-nothing)' 방식으로 유지하려는 경우에 효과적이다. 하지만 함수 전체가 강력한 예외 안전성을 갖도록 보장하지는 않는다. 다음과 같은 예시를 보자.

void someFunc()
{
  // 이 함수의 현재 상태에 대해 사본 생성
  ...
  
  f1();
  f2();
  
  // 변경된 상태를 바꾸어 넣기
  ...
}

f1 혹은 f2 에서 보장하는 예외 안전성이 '강력'하지 못하면, someFunc 함수 역시 강력한 예외 안전성을 보장하기 힘들어진다. 즉, "어떤 함수가 제공하는 예외 안전성 보장의 강도는, 그 함수가 내부적으로 호출하는 함수들이 제공하는 가장 약한 보장을 넘지 않는다" 는 핵심 3번의 내용과도 궤를 함께한다.

Comments