- 예외 안전성을 갖춘 함수는 실행 중 예외가 발생되더라도 자원을 누출시키지 않으며 자료구조를 더럽힌 채로 내버려 두지 않는다. 이런 함수들이 제공할 수 있는 예외 안전성 보장은 기본적인 보장, 강력한 보장, 예외 금지 보장이 있다.
- 강력한 예외 안전성 보장은 copy-and-swap방법을 써서 구현할 수 있지만, 모든 함수에 대해 실용적인 것은 아니다.
- 어떤 함수가 제공하는 예외 안전성 보장의 강도는 그 함수가 내부적으로 호출하는 함수들이 제공하는 가장 약한 보장을 넘지 않는다.
예외 안전성 확보
예외 안전성을 확보하는 작업은 매우 어렵다.
배경그림이 나오는 GUI 메뉴를 구현하기 위해 클래스를 하나 만든다고 가정하자.
스레딩 환경에서 동작할 수 있도록 설계되어 병행성 제어를 위해 mutex를 갖고 있다.
class PrettyMenu {
public:
...
void changeBackground(std::istream& imgSrc); // 배경그림을 바꾸는 멤버 함수
...
private:
Mutex mutex;
Image *bgImage; // 배경그림
int imageChanges; // 배경그림이 바뀐 횟수
};
void PrettyMenu::changeBackground(std::istream& imgSrc)
{
lock(&mutex); // mutex 획득
delete bgImage; // 현재 배경그림을 없앰
++imageChanges;
bgImage = new Image(imgSrc); // 새 배경그림
unlock(&mutex); // mutex 해제
}
예외 안전성이라는 측면에서 볼 때 이 함수는 매우 좋지 않다.
예외 안전성을 확보하려면 두 가지의 요구사항을 맞추어야 한다.
- 자원이 누출되지 않아야 한다.
- 자료구조가 더럽혀지는 것을 허용하지 않아야 한다.
그런데 위의 코드는 new Image를 수행하던 중 예외가 발생하면 unlock이 수행되지 않아 자원이 누출될 수 있다.
또한, bgImage가 가리키는 객체는 이미 삭제된 후이다. 그리고 배경이 제대로 바뀌지 않았는데 바뀐 횟수는 증가된다.
자원 누출을 막기 위해서는 자원 관리 객체를 만들어 사용하는 방법이 있다.
void PrettyMenu::changeBackGround(std::istream& imgSrc)
{
Lock m1(&mutex);
delete bgImage;
++imageChanges;
bgImage = new Image(imgSrc);
}
다음으론 자료구조 오염 문제를 해결해야 한다.
이때, 몇 가지 선택을 해야 하는데 그 전에 용어 정의가 필요하다.
용어 정의
- 기본적인 보장: 함수 동작 중에 예외가 발생하면, 실행 중인 프로그램에 관련된 모든 것들을 유효한 상태로 유지하겠다는 보장이다. 어떤 객체나 자료구조도 더럽히지 않으며, 모든 객체의 상태는 내부적으로 일관성을 유지한다. 하지만, 프로그램의 상태가 정확히 어떠한지 예측이 안될 수 있다.
- 강력한 보장: 함수 동작 중에 예외가 발생하면, 프로그램의 상태를 절대로 변경하지 않겠다는 보장이다. 이런 함수를 호출하는 것은 원자적인 동작이라고 할 수 있다. 호출이 성공하면 마무리까지 완벽하게 성공하고, 호출이 실패하면 함수 호출이 없었던 것처럼 프로그램의 상태가 되돌아간다.
- 예외불가 보장: 예외를 절대로 던지지 않겠다는 보장이다. 약속한 동작은 언제나 끝까지 완수하는 함수라는 뜻이다. 기본제공 타입에 대한 모든 연산은 예외를 던지지 않게 되어있다. 예외에 안전한 코드를 만들기 위한 가장 기본적이며 핵심적인 요소라 할 수 있다.
예외 안전성을 갖춘 함수는 위의 세 가지 보장 중 하나를 반드시 제공해야 한다.
위의 세 가지 보장 중 실용적인 것은 강력한 보장이다. 예외 안전성만 따지면 예외불가 보장이 좋겠지만 예외를 던질 수 있는 함수를 호출하지 않고 프로그래밍을 하는 것은 힘들기 때문이다.
changeBackground 함수를 봐보자. 이 함수의 경우엔 강력한 보장을 거의 제공하는 것은 그다지 어렵지 않다.
우선, PrettyMenu의 bhImage데이터 멤버의 타입을 기본제공 포인터 타입인 Image*에서 자원관리 전담 포인터로 바꾼다. 예외 안전성을 보장함과 동시에 자원 누출을 막을 수 있게 된다.
그리고, changeBackgound 함수 내의 문장을 재배치해서 배경그림이 진짜로 바뀌기 전에는 imageChanges를 증가시키지 않도록 만든다. 어떤 동작이 일어났는지를 나타내는 객체를 프로그램 내에서 쓰는 경우에는 해당 동작이 실제로 일어날 때까지 그 객체의 상태를 바꾸지 않는 편이 좋다.
class PrettyMenu {
...
std::tr1::shared_ptr<Image> bgImage;
...
};
void PrettyMenu::changeBackgound(std::istream& imgSrc)
{
Lock m1(&mutex);
bjImage.reset(new Image(imgSrc));
++ImageChanges;
}
이제는 이전의 배경그림을 직접 삭제할 필요가 없다. 스마트 포인터가 관리를 해주기 때문이다.
게다가 새로운 배경그림이 제대로 만들어졌을 때만 이전 배경그림의 삭제 작업이 이루어지며, 이 때 예외가 발생한다면 reset이 실행되지 않기 때문에 프로그램의 결과를 예측하기도 쉽다.
이것만으로 끝이 아니다. 매개변수가 istream으로 되어있다. Image 클래스의 생성자를 실행하다가 예외를 일으키면 그 시점에 입력 스트림의 읽기 표시자가 이동한 채로 남아 있을 가능성이 충분하다.
이 표시자의 이동이 전체 프로그램의 나머지에 영향을 미칠 수 있는 어떤 변화로 작용할 수도 있다.
따라서 changeBackground의 예외 안전성은 기본적인 보장이다.
매개변수 타입으로 istream을 쓰지 말고, 다른 타입으로 변경하면 해결 가능하다.
예외 안전성 보장 설계 전략
예외 안전성을 보장하는 일반적인 설계 전략은 copy-and-swap이다.
어떤 객체를 수정하고 싶으면 그 객체의 사본을 하나 만들어 놓고 그 사본을 수정하는 것이다.
이렇게 하면 수정 동작 중에 실행되는 연산에서 예외가 발생되더라도 원본 객체는 바뀌지 않을 채로 남게 된다.
필요한 동작이 전부 완료되면 수정된 객체를 원본 객체와 맞바꾸는데, 이 작업을 예외를 던지지 않는 연산 내부에서 수행한다.
이 전략은 진짜 객체의 모든 데이터를 별도의 구현객체에 넣어두고, 그 구현 객체를 가리키는 포인터를 객체가 갖고 있게 하는 식으로 구현한다. 이를 pimpl이라고 한다.
struct PMImpl {
std::tr1::shared_ptr<Image> bgImage;
int imageChange;
};
class PrettyMenu {
...
private:
Mutex mutex;
std::tr1::shared_ptr<PMImpl> pImpl;
};
void PrettyMenu::changeBackground(std::itream& imgSrc)
{
using std::swap;
Lock m1(&mutex);
std::tr1::shared_ptr<PMImpl> pNew(new PMImpl(*pImpl));
pNew->bgImage.reset(new Image(imgSrc));
++pNew->imageChanges;
swap(pImpl, pNew);
}
PMImpl이 클래스가 아니라 구조체로 만들어져 있는데 PrettyMenu 클래스에서 pImpl이 private 멤버로 되어 있어서 구현 객체의 데이터가 바로 캡슐화되기 때문에 상관이 없다.
copy-and-swap 전략은 객체의 상태를 전부 바꾸거나 혹은 아예 바꾸지 않는 방식으로 유지하려는 경우에 좋다.
그러나 함수 전체가 강력한 예외 안전성을 갖도록 보장하지는 않는다.
만약, 함수의 구조가 다음과 같다고 가정해 보자.
void someFunc()
{
...
f1();
f2();
...
}
f1 혹은 f2에서 강력한 예외 안전성을 보장하지 못하면 someFunc 함수 역시 강력한 예외 안전성을 보장하기 힘들다.
f1 및 f2 모두가 강력한 보장을 한다고 해도 두 함수 모두 성공한다는 보장이 없기에 someFunc 자체는 강력한 보장을 할 수 없다.
이것은 함수의 부수효과 때문이다. 자기 자신에만 국한된 것들의 상태를 바꾸며 동작하는 함수의 경우에는 강력한 보장을 제공하기가 비교적 수월하지만 비지역 데이터에 대해 부수효과를 주는 함수는 강력한 보장이 어렵다.
예를 들어 f1을 호출하고 나서 부수효과로 데이터베이스가 변경된다고 하면 someFunc이 제어할 방법이 없다.
강력한 예외 안전성 보장을 제공하였다 하더라도 효율 문제도 무시할 수 없다.
copy-and-swap 기법은 데이터의 사본을 만들어야 된다. 이 사본을 만드는 과정에서 비용이 많이 발생할 수 있다.
그럼에도 예외 안전성은 강력한 보장을 해주는 것이 좋다.
만약, 강력한 보장을 할 수 없다면 기본적인 보장 쪽으로 변경하려 할 수 있다.
효율 혹은 복잡성에서 생기는 비용을 고려해야 하는 상황도 있기 때문이다. 그렇기에 실용성이 확보될 때만 강력한 보장을 제공하고 그렇지 않으면 기본적인 보장을 우선적으로 생각해라.
어떠한 함수에서 호출하는 또 다른 함수의 안전성 보장에 따라 함수의 예외 안전성의 정도가 정해진다.
또한, 함수뿐만 아니라 시스템 전체에도 영향일 미치기 때문에 조심해야 한다.
그렇기에 코드를 작성할 때 항상 예외 안전성을 생각하며 작성해야 한다. 어쩔 수 없이 래거시 코드를 사용해야 한다면 문서화하여 이후에 파악하기 쉽게 해야 한다.