자기대입(self assignment)이란, 어떤 객체가 자기 자신에 대해 대입 연산자를 적용하는 것을 말합니다.
이 코드는 적법한(legal) 코드인데다가 대입이란 연산이 그렇게 눈에 잘 띄는 것도 아니라 큰 문제입니다.
a[i] = a[j]; // 자기 대입의 가능성을 품은 문장
위의 문장은 i 및 j가 같은 값을 갖게 되면 자기대입문이 됩니다.
*px = *py; // 자기 대입의 가능성을 품은 문장
이 문장의 경우, px 및 py가 가리키는 대상이 같으면 자기대입이 되고 맙니다.
언뜻 보기에 명확하지 않은 이러한 자기대입이 생기는 이유는 여러 곳에서 하나의 객체를 참조하는 상태, 다시 말해 중복참조(aliasing)라고 불리는 것 때문입니다.
같은 타입으로 만들어진 객체 여러 개를 참조자 혹은 포인터로 물어 놓고 동작하는 코드를 작성할 때는 같은 객체가 사용될 가능성을 고려하는 것이 일반적으로 바람직한 자세가 되겠습니다.
여러분들은 자원 관리 용도로 항상 객체를 만들어야 할 것이고, 이렇게 만든 자원 관리 객체들이 복사될 때 나름대로 잘 동작하도록 코딩할 게 분명합니다. 바로 이때 조심해야 하는 것이 대입 연산자입니다. 이 연산자는 여러분이 신경 쓰지 않아도 자기대입에 대해 안전하게 동작해야 합니다. 어쩌다 보면 자원을 사용하기 전에 덜컥 해제해 버릴 수도 있을지 모릅니다. 동적 할당된 비트맵을 가리키는 원시 포인터를 데이터 멤버로 갖는 클래스를 하나 만들었다고 가정해 봅시다.
1 2 3 4 5 6 7 8 | class Bitmap { ... }; class Widget { ... private: Butmap *pb; // 힙에 할당한 객체를 가리키는 }; | cs |
이젠 겉보기에 멀쩡해 보이는 operator=의 구현 코드를 보시겠습니다. 의미적으로는 문제가 없을 것 같지만 자기 참조의 가능성이 있는 위험천만의 코드입니다.
1 2 3 4 5 6 7 | Widget& Widget::operator=(const Widget& rhs) // 안전하지 않게 구현된 operator= { delete pb; // 현재의 비트맵 사용을 중지합니다. pb = new Bitmap(*rhs.pb); // 이제 rhs의 비트맵을 사용하도록 만듭니다. return *this; } | cs |
여기서 찾을 수 있는 자기 참조 문제는 operator= 내부에서 *this(대입되는 대상)와 rhs가 같은 객체일 가능성이 있다는 것입니다. 이 둘이 같은 객체이면, delete 연산자가 *this 객체의 비트맵에만 적용되는 것이 아니라 rhs의 객체까지 적용되어 버립니다. 그러니까, 이 함수가 끝나는 시점이 되면 해당 Widget(자기 참조에 의해 변경되면 큰일 나는 바로 그 Widget) 객체는 자신의 포인터 멤버를 통해 물고 있던 객체가 어처구니없게도 삭제된 상태가 되는 불상사를 당하게 됩니다.
이런 에러에 대한 대책은 예전부터 있어 왔습니다. 전통적인 방법은 operator=의 첫머리에서 일치성 검사(identity test)를 통해 자기대입을 점검하는 것이죠.
1 2 3 4 5 6 7 8 9 | Widget& Widget::operator=(const Widget& rhs) { if (this == &rhs) return *this; // 객체가 같은지, 즉 자기대입인지 검사합니다. // 자기대입이면 아무것도 안 합니다. delete pb; pb = new Bitmap(*rhs.pb); return *this; } | cs |
이렇게 하면 되기는 하지만, 예외 안전성에 대해서는 이번 것도 여전히 문젯거리를 안고 있습니다. 특히 신경 쓰이는 부분이 'new Bitmap' 표현식입니다. 이 부분에서 예외가 터지게 되면(동적 할당에 필요한 메모리가 부족하다든지 Bitmap 클래스 복사 생성자에서 예외를 던진다든지 해서), Widget 객체는 결국 삭제된 Bitmap을 가리키는 포인터를 껴안고 홀로 남고 맙니다. 이런 포인터는 delete 연산자를 안전하게 적용할 수도 없고, 안전하게 읽는 것조차 불가능합니다.
다행스럽게도 operator=을 예외에 안전하게 구현하면 대개 자기대입에도 안전한 코드가 나오게 되어 있습니다. 일단 이번 항목에서는 "많은 경우에 문장 순서를 세심하게 바꾸는 것만으로 예외에 안전한(동시에 자기대입에 안전한) 코드가 만들어진다"라는 법칙 한 가지를 여기서 써먹어 보도록 하겠습니다. 지금의 코드는, pb를 무턱대고 삭제하지 말고 이 포인터가 가리키는 객체를 복사한 직후에 삭제하면 깔끔히 해결될 것 같습니다.
1 2 3 4 5 6 7 8 | Widget& Widget::operator=(const Widget& rhs) { Bitmap *pOrig = pb; // 원래의 pb를 어딘가에 기억해 둡니다. pb = new Bitmap(*rhs.pb); // 다음, pb가 *pb의 사본을 가리키게 만듭니다. delete pOrig; // 원래의 pb를 삭제합니다. return *this; } | cs |
이 코드는 이제 예외에 안전합니다. 'new Bitmap' 부분에서 예외가 발생하더라도 pb(그리고 이 포인터가 들어 있는 Widget)는 변경되지 않은 상태가 유지되기 때문이죠. 게다가 일치성 검사 같은 것이 없음에도 불구하고 이 코드는 자기대입 현상을 완벽히 처리하고 있습니다. 원본 비트맵을 복사해 놓고, 복사해 놓은 사본을 포인터가 가리키게 만든 후, 원본을 삭제하는 순서로 실행되기 때문입니다.
예외 안전성과 자기대입 안전성을 동시에 가진 operator=을 구현하는 방법으로, 문장의 실행 순서를 수작업으로 조정하는 것 외에 다른 방법을 하나 더 알려드리겠습니다. 바로 '복사 후 맞바꾸기(copy and swap)'라고 알려진 기법입니다.
1 2 3 4 5 6 7 8 9 10 11 12 | class Widget { ... void swap(Widget& rhs); // *this의 데이터 및 rhs의 데이터를 맞바꿉니다. ... }; Widget& Widget::operator=(const Widget& rhs) { Widget temp(rhs); // rhs의 데이터에 대해 사본을 하나 만듭니다. swap(temp); // *this의 데이터를 그 사본의 것과 맞바꿉니다. return *this; } | cs |
이 방법은 C++이 가진 두 가지 특징을 활용해서 조금 다르게 구현할 수도 있습니다. 클래스의 복사 대입 연산자는 인자를 값으로 취하도록 선언하는 것이 가능하다는 점과, 값에 의한 전달을 수행하면 전달된 대상의 사본이 생긴다는 점을 이용하는 것이죠.
1 2 3 4 5 6 | Widget& Widget::operator=(Widget rhs) // rhs는 넘어온 원래 객체의 사본입니다. { swap(temp); // *this의 데이터를 이 사본의 // 데이터와 맞바꿉니다. return *this; } | cs |
이 코드는 명확성을 재물로 바친 코드입니다. 하지만 객체를 복사하는 코드가 함수 본문으로부터 매개변수의 생성자로 옮겨졌기 때문에, 컴파일러가 더 효율적인 코드를 생성할 수 있는 여지가 만들어지는 것은 사실입니다.
operator=을 구현할 때, 어떤 객체가 그 자신에 대입되는 경우를 제대로 처리하도록 만듭시다.
원본 객체와 복사대상 객체의 주소를 비교해도 되고, 문장의 순서를 적절히 조정할 수도 있으며, 복사 후 맞바꾸기 기법을 써도 됩니다.
두 개 이상의 객체에 대해 동작하는 함수가 있다면, 이 함수에 넘겨지는 객체들이 사실 같은 객체인 경우에 정확하게 동작하는지 확인해 보세요.